Compare commits

..

1 Commits

Author SHA1 Message Date
091c33a73c Test Hapus Auth 2025-11-27 18:13:29 +08:00
85 changed files with 1475 additions and 4128 deletions

View File

@@ -136,7 +136,6 @@ model MediaSosial {
name String name String
image FileStorage? @relation(fields: [imageId], references: [id]) image FileStorage? @relation(fields: [imageId], references: [id])
imageId String? imageId String?
icon String?
iconUrl String? @db.VarChar(255) iconUrl String? @db.VarChar(255)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -783,22 +782,24 @@ model Penghargaan {
// ========================================= FASILITAS KESEHATAN ========================================= // // ========================================= FASILITAS KESEHATAN ========================================= //
model FasilitasKesehatan { model FasilitasKesehatan {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id]) informasiumum InformasiUmum @relation(fields: [informasiUmumId], references: [id])
informasiUmumId String informasiUmumId String
layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id]) layananunggulan LayananUnggulan @relation(fields: [layananUnggulanId], references: [id])
layananUnggulanId String layananUnggulanId String
dokterdantenagamedis DokterdanTenagaMedis[] @relation("Dokter") dokterdantenagamedis DokterdanTenagaMedis @relation(fields: [dokterdanTenagaMedisId], references: [id])
fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id]) dokterdanTenagaMedisId String
fasilitasPendukungId String fasilitaspendukung FasilitasPendukung @relation(fields: [fasilitasPendukungId], references: [id])
prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id]) fasilitasPendukungId String
prosedurPendaftaranId String prosedurpendaftaran ProsedurPendaftaran @relation(fields: [prosedurPendaftaranId], references: [id])
tarifdanlayanan TarifDanLayanan[] @relation("Tarif") prosedurPendaftaranId String
tarifdanlayanan TarifDanLayanan @relation(fields: [tarifDanLayananId], references: [id])
tarifDanLayananId String
} }
model InformasiUmum { model InformasiUmum {
@@ -824,20 +825,15 @@ model LayananUnggulan {
} }
model DokterdanTenagaMedis { model DokterdanTenagaMedis {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
specialist String specialist String
jadwal String jadwal String
jadwalLibur String createdAt DateTime @default(now())
jamBukaOperasional String updatedAt DateTime @updatedAt
jamTutupOperasional String deletedAt DateTime @default(now())
jamBukaLibur String isActive Boolean @default(true)
jamTutupLibur String FasilitasKesehatan FasilitasKesehatan[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
FasilitasKesehatan FasilitasKesehatan[] @relation("Dokter")
} }
model FasilitasPendukung { model FasilitasPendukung {
@@ -868,7 +864,7 @@ model TarifDanLayanan {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deletedAt DateTime @default(now()) deletedAt DateTime @default(now())
isActive Boolean @default(true) isActive Boolean @default(true)
FasilitasKesehatan FasilitasKesehatan[] @relation("Tarif") FasilitasKesehatan FasilitasKesehatan[]
} }
// ========================================= JADWAL KEGIATAN ========================================= // // ========================================= JADWAL KEGIATAN ========================================= //

View File

@@ -1,76 +0,0 @@
'use client';
import { Box, Image, Select, rem } from '@mantine/core';
const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
type SosmedKey = keyof typeof sosmedMap;
const sosmedList = Object.entries(sosmedMap).map(([value, item]) => ({
value,
label: item.label,
}));
export default function SelectSosialMedia({
value,
onChange,
}: {
value: SosmedKey;
onChange: (value: SosmedKey) => void;
}) {
const selected = value;
const selectedImage = sosmedMap[selected]?.src;
return (
<Box maw={300}>
<Select
placeholder="Pilih sosial media"
value={selected}
data={sosmedList}
searchable={false}
withCheckIcon={false}
onChange={(val) => val && onChange(val as SosmedKey)}
styles={{
input: {
textAlign: 'left',
fontSize: rem(16),
paddingLeft: 36,
},
section: {
left: 10,
right: 'auto',
},
}}
/>
{/* 🔥 PREVIEW DIPISAH DI LUAR SELECT */}
{selectedImage && (
<Box mt="md">
<Image
alt=""
src={selectedImage}
radius="md"
style={{
width: 120,
height: 120,
objectFit: 'contain',
border: '1px solid #eee',
padding: 8,
}}
/>
</Box>
)}
</Box>
);
}

View File

@@ -1,56 +0,0 @@
'use client';
import { Box, Select } from '@mantine/core';
import { useEffect, useState } from 'react';
export const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
type SosmedKey = keyof typeof sosmedMap;
const sosmedList = Object.entries(sosmedMap).map(([value, item]) => ({
value,
label: item.label,
}));
export default function SelectSocialMediaEdit({
value,
onChange,
}: {
value: string;
onChange: (val: SosmedKey) => void;
}) {
const [selected, setSelected] = useState<SosmedKey>('facebook');
useEffect(() => {
if (value && sosmedMap[value as SosmedKey]) {
setSelected(value as SosmedKey);
}
}, [value]);
return (
<Box>
<Select
label="Jenis Media Sosial"
value={selected}
data={sosmedList}
searchable={false}
onChange={(val) => {
if (!val) return;
setSelected(val as SosmedKey);
onChange(val as SosmedKey);
}}
/>
</Box>
);
}

View File

@@ -9,30 +9,29 @@ import { z } from "zod";
// Validasi form // Validasi form
const templateForm = z.object({ const templateForm = z.object({
name: z.string().min(1, "Nama harus diisi"), name: z.string().min(1, "Nama harus diisi"),
informasiUmum: z.object({ informasiUmum: z.object({
fasilitas: z.string().min(1), fasilitas: z.string().min(1, "Fasilitas harus diisi"),
alamat: z.string().min(1), alamat: z.string().min(1, "Alamat harus diisi"),
jamOperasional: z.string().min(1), jamOperasional: z.string().min(1, "Jam operasional harus diisi"),
}), }),
layananUnggulan: z.object({ layananUnggulan: z.object({
content: z.string().min(1), content: z.string().min(1, "Layanan unggulan harus diisi"),
}),
dokterdanTenagaMedis: z.object({
name: z.string().min(1, "Nama dokter harus diisi"),
specialist: z.string().min(1, "Spesialis harus diisi"),
jadwal: z.string().min(1, "Jadwal harus diisi"),
}), }),
// NOW ARRAY OF STRING (ID)
dokterdanTenagaMedis: z.array(z.string()).min(1, "Minimal pilih 1 dokter"),
fasilitasPendukung: z.object({ fasilitasPendukung: z.object({
content: z.string().min(1), content: z.string().min(1, "Fasilitas pendukung harus diisi"),
}), }),
prosedurPendaftaran: z.object({ prosedurPendaftaran: z.object({
content: z.string().min(1), content: z.string().min(1, "Prosedur pendaftaran harus diisi"),
}),
tarifDanLayanan: z.object({
layanan: z.string().min(1, "Layanan harus diisi"),
tarif: z.string().min(1, "Tarif harus diisi"),
}), }),
// NOW ARRAY OF STRING (ID)
tarifDanLayanan: z.array(z.string()).min(1, "Minimal pilih 1 tarif"),
}); });
// Default form kosong // Default form kosong
@@ -46,34 +45,21 @@ const defaultForm = {
layananUnggulan: { layananUnggulan: {
content: "", content: "",
}, },
dokterdanTenagaMedis: {
dokterdanTenagaMedis: [] as string[], // ← array kosong name: "",
tarifDanLayanan: [] as string[], // ← array kosong specialist: "",
jadwal: "",
},
fasilitasPendukung: { fasilitasPendukung: {
content: "", content: "",
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: "", content: "",
}, },
}; tarifDanLayanan: {
layanan: "",
type DokterItem = { tarif: "",
id: string; },
name: string;
specialist: string;
jadwal: string;
jadwalLibur: string;
jamBukaOperasional: string;
jamTutupOperasional: string;
jamBukaLibur: string;
jamTutupLibur: string;
};
type TarifItem = {
id: string;
layanan: string;
tarif: string;
}; };
const fasilitasKesehatan = proxy({ const fasilitasKesehatan = proxy({
@@ -200,26 +186,33 @@ const fasilitasKesehatan = proxy({
const result = await res.json(); const result = await res.json();
const data = result.data; const data = result.data;
this.id = data.id;
this.form = { fasilitasKesehatan.edit.id = data.id;
fasilitasKesehatan.edit.form = {
name: data.name, name: data.name,
informasiUmum: { informasiUmum: {
fasilitas: data.informasiumum.fasilitas, fasilitas: data.informasiumum.fasilitas,
alamat: data.informasiumum.alamat, alamat: data.informasiumum.alamat,
jamOperasional: data.informasiumum.jamOperasional, jamOperasional: data.informasiumum.jamOperasional,
}, },
layananUnggulan: {
content: data.layananunggulan.content,
},
dokterdanTenagaMedis: {
name: data.dokterdantenagamedis.name,
specialist: data.dokterdantenagamedis.specialist,
jadwal: data.dokterdantenagamedis.jadwal,
},
fasilitasPendukung: { fasilitasPendukung: {
content: data.fasilitaspendukung.content, content: data.fasilitaspendukung.content,
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: data.prosedurpendaftaran.content, content: data.prosedurpendaftaran.content,
}, },
// map relasi -> array of IDs tarifDanLayanan: {
layananUnggulan: { layanan: data.tarifdanlayanan.layanan,
content: data.layananunggulan.content, tarif: data.tarifdanlayanan.tarif,
}, },
dokterdanTenagaMedis: data.dokterdantenagamedis?.map((v: DokterItem) => v.id) ?? [],
tarifDanLayanan: data.tarifdanlayanan?.map((v: TarifItem) => v.id) ?? [],
}; };
}, },
async submit() { async submit() {
@@ -245,15 +238,22 @@ const fasilitasKesehatan = proxy({
layananUnggulan: { layananUnggulan: {
content: fasilitasKesehatan.edit.form.layananUnggulan.content, content: fasilitasKesehatan.edit.form.layananUnggulan.content,
}, },
dokterdanTenagaMedis: dokterdanTenagaMedis: {
fasilitasKesehatan.edit.form.dokterdanTenagaMedis, name: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.name,
specialist:
fasilitasKesehatan.edit.form.dokterdanTenagaMedis.specialist,
jadwal: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.jadwal,
},
fasilitasPendukung: { fasilitasPendukung: {
content: fasilitasKesehatan.edit.form.fasilitasPendukung.content, content: fasilitasKesehatan.edit.form.fasilitasPendukung.content,
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content, content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content,
}, },
tarifDanLayanan: fasilitasKesehatan.edit.form.tarifDanLayanan, tarifDanLayanan: {
layanan: fasilitasKesehatan.edit.form.tarifDanLayanan.layanan,
tarif: fasilitasKesehatan.edit.form.tarifDanLayanan.tarif,
},
}; };
const res = await fetch( const res = await fetch(
@@ -320,26 +320,12 @@ const templateDokterForm = z.object({
name: z.string().min(1, "Nama tidak boleh kosong"), name: z.string().min(1, "Nama tidak boleh kosong"),
specialist: z.string().min(1, "Spesialis tidak boleh kosong"), specialist: z.string().min(1, "Spesialis tidak boleh kosong"),
jadwal: z.string().min(1, "Jadwal tidak boleh kosong"), jadwal: z.string().min(1, "Jadwal tidak boleh kosong"),
jadwalLibur: z.string().min(1, "Jadwal libur tidak boleh kosong"),
jamBukaOperasional: z
.string()
.min(1, "Jam buka operasional tidak boleh kosong"),
jamTutupOperasional: z
.string()
.min(1, "Jam tutup operasional tidak boleh kosong"),
jamBukaLibur: z.string().min(1, "Jam buka libur tidak boleh kosong"),
jamTutupLibur: z.string().min(1, "Jam tutup libur tidak boleh kosong"),
}); });
const defaultDokterForm = { const defaultDokterForm = {
name: "", name: "",
specialist: "", specialist: "",
jadwal: "", jadwal: "",
jadwalLibur: "",
jamBukaOperasional: "",
jamTutupOperasional: "",
jamBukaLibur: "",
jamTutupLibur: "",
}; };
const dokter = proxy({ const dokter = proxy({
@@ -477,11 +463,6 @@ const dokter = proxy({
name: data.name, name: data.name,
specialist: data.specialist, specialist: data.specialist,
jadwal: data.jadwal, jadwal: data.jadwal,
jadwalLibur: data.jadwalLibur,
jamBukaOperasional: data.jamBukaOperasional,
jamTutupOperasional: data.jamTutupOperasional,
jamBukaLibur: data.jamBukaLibur,
jamTutupLibur: data.jamTutupLibur,
}; };
return data; // Return the loaded data return data; // Return the loaded data
} else { } else {
@@ -506,11 +487,6 @@ const dokter = proxy({
name: this.form.name, name: this.form.name,
specialist: this.form.specialist, specialist: this.form.specialist,
jadwal: this.form.jadwal, jadwal: this.form.jadwal,
jadwalLibur: this.form.jadwalLibur,
jamBukaOperasional: this.form.jamBukaOperasional,
jamTutupOperasional: this.form.jamTutupOperasional,
jamBukaLibur: this.form.jamBukaLibur,
jamTutupLibur: this.form.jamTutupLibur,
}; };
const cek = templateDokterForm.safeParse(formData); const cek = templateDokterForm.safeParse(formData);
@@ -591,255 +567,9 @@ const dokter = proxy({
}, },
}); });
const templateTarifForm = z.object({
tarif: z.string().min(1, "Tarif tidak boleh kosong"),
layanan: z.string().min(1, "Layanan tidak boleh kosong"),
});
const defaultTarifForm = {
tarif: "",
layanan: "",
};
const tarif = proxy({
create: {
form: defaultTarifForm,
loading: false,
async create() {
const cek = templateTarifForm.safeParse(tarif.create.form);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return null;
}
try {
tarif.create.loading = true;
const res = await ApiFetch.api.kesehatan.tarifdanlayanan["create"].post(
tarif.create.form
);
if (res.status === 200) {
const id = res.data?.data;
if (id) {
toast.success("Sukses menambahkan");
tarif.create.form = { ...defaultTarifForm };
tarif.findMany.load();
return id;
}
}
toast.error("failed create");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
tarif.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.TarifDanLayananGetPayload<{
omit: {
isActive: true;
};
}>[]
| null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "") => {
tarif.findMany.loading = true; // ✅ Akses langsung via nama path
tarif.findMany.page = page;
tarif.findMany.search = search;
try {
const query: any = { page, limit };
if (search) query.search = search;
const res = await ApiFetch.api.kesehatan.tarifdanlayanan[
"findMany"
].get({ query });
if (res.status === 200 && res.data?.success) {
tarif.findMany.data = res.data.data ?? [];
tarif.findMany.totalPages = res.data.totalPages ?? 1;
} else {
tarif.findMany.data = [];
tarif.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch tarif dan layanan paginated:", err);
tarif.findMany.data = [];
tarif.findMany.totalPages = 1;
} finally {
tarif.findMany.loading = false;
}
},
},
findUnique: {
data: null as Prisma.TarifDanLayananGetPayload<{
omit: { isActive: true };
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`);
if (res.ok) {
const data = await res.json();
tarif.findUnique.data = data.data ?? null;
} else {
console.error(
"Failed to fetch tarif dan layanan",
res.statusText
);
tarif.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching tarif dan layanan", error);
tarif.findUnique.data = null;
}
},
},
update: {
id: "",
form: { ...defaultTarifForm },
loading: false,
async load(id: string) {
if (!id) {
toast.warn("ID tidak valid");
return null;
}
try {
const response = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
tarif: data.tarif,
layanan: data.layanan
};
return data; // Return the loaded data
} else {
throw new Error(result?.message || "Gagal memuat data");
}
} catch (error) {
console.error("Error loading tarif dan layanan:", error);
toast.error(
error instanceof Error ? error.message : "Gagal memuat data"
);
return null;
}
},
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
tarif: this.form.tarif,
layanan: this.form.layanan
};
const cek = templateTarifForm.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues
.map((v: any) => `${v.path.join(".")}`)
.join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kesehatan/tarifdanlayanan/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await tarif.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data tarif dan layanan");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) {
return toast.warn("ID tidak valid");
}
try {
tarif.delete.loading = true;
const response = await fetch(
`/api/kesehatan/tarifdanlayanan/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(
result.message || "tarif dan layanan berhasil dihapus"
);
await tarif.findMany.load(); // refresh list
} else {
toast.error(
result?.message || "Gagal menghapus tarif dan layanan"
);
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus tarif dan layanan");
} finally {
tarif.delete.loading = false;
}
},
},
});
const fasilitasKesehatanState = proxy({ const fasilitasKesehatanState = proxy({
fasilitasKesehatan, fasilitasKesehatan,
dokter, dokter,
tarif
}); });
export default fasilitasKesehatanState; export default fasilitasKesehatanState;

View File

@@ -27,7 +27,7 @@ const programInovasi = proxy({
name: "", name: "",
description: "", description: "",
imageId: "", imageId: "",
link: "", link: ""
} as ProgramInovasiForm, } as ProgramInovasiForm,
loading: false, loading: false,
async create() { async create() {
@@ -71,21 +71,20 @@ const programInovasi = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
// Change to arrow function programInovasi.findMany.loading = true; // Use the full path to access the property
programInovasi.findMany.loading = true; // Use the full path to access the property
programInovasi.findMany.page = page; programInovasi.findMany.page = page;
programInovasi.findMany.search = search; programInovasi.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.programinovasi[ const res = await ApiFetch.api.landingpage.programinovasi[
"findMany" "findMany"
].get({ ].get({
query, query
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
programInovasi.findMany.data = res.data.data || []; programInovasi.findMany.data = res.data.data || [];
programInovasi.findMany.total = res.data.total || 0; programInovasi.findMany.total = res.data.total || 0;
@@ -390,10 +389,7 @@ const pejabatDesa = proxy({
try { try {
// Ensure ID is properly encoded in the URL // Ensure ID is properly encoded in the URL
const url = new URL( const url = new URL(`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`, window.location.origin);
`/api/landingpage/pejabatdesa/${encodeURIComponent(this.id)}`,
window.location.origin
);
const response = await fetch(url.toString(), { const response = await fetch(url.toString(), {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -442,19 +438,16 @@ const pejabatDesa = proxy({
const templateMediaSosial = z.object({ const templateMediaSosial = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"), name: z.string().min(3, "Nama minimal 3 karakter"),
imageId: z.string().nullable().optional(), imageId: z.string().min(1, "Gambar wajib dipilih"),
iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"), iconUrl: z.string().min(3, "Icon URL minimal 3 karakter"),
icon: z.string().nullable().optional(),
}); });
type MediaSosialForm = { type MediaSosialForm = {
name: string; name: string;
imageId: string | null; // boleh null imageId: string;
iconUrl: string; iconUrl: string;
icon: string | null; // boleh null
}; };
const mediaSosial = proxy({ const mediaSosial = proxy({
create: { create: {
form: {} as MediaSosialForm, form: {} as MediaSosialForm,
@@ -462,10 +455,9 @@ const mediaSosial = proxy({
async create() { async create() {
// Ensure all required fields are non-null // Ensure all required fields are non-null
const formData = { const formData = {
name: mediaSosial.create.form.name ?? "", name: mediaSosial.create.form.name || "",
imageId: mediaSosial.create.form.imageId ?? null, // FIXED imageId: mediaSosial.create.form.imageId || "",
iconUrl: mediaSosial.create.form.iconUrl ?? "", iconUrl: mediaSosial.create.form.iconUrl || "",
icon: mediaSosial.create.form.icon ?? null, // FIXED
}; };
const cek = templateMediaSosial.safeParse(formData); const cek = templateMediaSosial.safeParse(formData);
@@ -500,19 +492,20 @@ const mediaSosial = proxy({
total: 0, total: 0,
loading: false, loading: false,
search: "", search: "",
load: async (page = 1, limit = 10, search = "") => { load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
// Change to arrow function mediaSosial.findMany.loading = true; // Use the full path to access the property
mediaSosial.findMany.loading = true; // Use the full path to access the property
mediaSosial.findMany.page = page; mediaSosial.findMany.page = page;
mediaSosial.findMany.search = search; mediaSosial.findMany.search = search;
try { try {
const query: any = { page, limit }; const query: any = { page, limit };
if (search) query.search = search; if (search) query.search = search;
const res = await ApiFetch.api.landingpage.mediasosial["findMany"].get({ const res = await ApiFetch.api.landingpage.mediasosial[
"findMany"
].get({
query, query,
}); });
if (res.status === 200 && res.data?.success) { if (res.status === 200 && res.data?.success) {
mediaSosial.findMany.data = res.data.data || []; mediaSosial.findMany.data = res.data.data || [];
mediaSosial.findMany.total = res.data.total || 0; mediaSosial.findMany.total = res.data.total || 0;
@@ -544,7 +537,7 @@ const mediaSosial = proxy({
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
} }
mediaSosial.update.loading = true; mediaSosial.update.loading = true;
try { try {
const res = await fetch(`/api/landingpage/mediasosial/${id}`); const res = await fetch(`/api/landingpage/mediasosial/${id}`);
@@ -593,72 +586,66 @@ const mediaSosial = proxy({
}, },
}, },
update: { update: {
id: "", id: "",
form: {} as MediaSosialForm, form: {} as MediaSosialForm,
loading: false, loading: false,
async load(id: string) { async load(id: string) {
if (!id) { if (!id) {
toast.warn("ID tidak valid"); toast.warn("ID tidak valid");
return null; return null;
}
mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
try {
const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
} }
const result = await response.json(); mediaSosial.update.loading = true; // ✅ Tambahkan ini di awal
if (result?.success) { try {
const data = result.data; const response = await fetch(`/api/landingpage/mediasosial/${id}`, {
this.id = data.id; method: "GET",
this.form = { headers: {
name: data.name || "", "Content-Type": "application/json",
imageId: data.imageId || null, },
iconUrl: data.iconUrl || "", });
icon: data.icon || null,
if (!response.ok) {
}; throw new Error(`HTTP error! status: ${response.status}`);
return data; }
} else {
throw new Error( const result = await response.json();
result?.message || "Gagal mengambil data media sosial"
); if (result?.success) {
const data = result.data;
this.id = data.id;
this.form = {
name: data.name || "",
imageId: data.imageId || "",
iconUrl: data.iconUrl || "",
};
return data;
} else {
throw new Error(result?.message || "Gagal mengambil data media sosial");
}
} catch (error) {
console.error((error as Error).message);
toast.error("Terjadi kesalahan saat mengambil data media sosial");
} finally {
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error
} }
} catch (error) { },
console.error((error as Error).message);
toast.error("Terjadi kesalahan saat mengambil data media sosial"); async update() {
} finally { const cek = templateMediaSosial.safeParse(mediaSosial.update.form);
mediaSosial.update.loading = false; // ✅ Supaya berhenti loading walau error if (!cek.success) {
} const err = `[${cek.error.issues
}, .map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
async update() { toast.error(err);
const cek = templateMediaSosial.safeParse(mediaSosial.update.form); return false;
if (!cek.success) { }
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`) try {
.join("\n")}] required`; mediaSosial.update.loading = true;
toast.error(err);
return false; const response = await fetch(`/api/landingpage/mediasosial/${this.id}`, {
}
try {
mediaSosial.update.loading = true;
const response = await fetch(
`/api/landingpage/mediasosial/${this.id}`,
{
method: "PUT", method: "PUT",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -667,40 +654,38 @@ const mediaSosial = proxy({
name: this.form.name, name: this.form.name,
imageId: this.form.imageId, imageId: this.form.imageId,
iconUrl: this.form.iconUrl, iconUrl: this.form.iconUrl,
icon: this.form.icon,
}), }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(
errorData.message || `HTTP error! status: ${response.status}`
);
} }
);
const result = await response.json();
if (!response.ok) {
const errorData = await response.json().catch(() => ({})); if (result.success) {
throw new Error( toast.success("Berhasil update media sosial");
errorData.message || `HTTP error! status: ${response.status}` await mediaSosial.findMany.load(); // refresh list
return true;
} else {
throw new Error(result.message || "Gagal update media sosial");
}
} catch (error) {
console.error("Error updating media sosial:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update media sosial"
); );
return false;
} finally {
mediaSosial.update.loading = false;
} }
},
const result = await response.json();
if (result.success) {
toast.success("Berhasil update media sosial");
await mediaSosial.findMany.load(); // refresh list
return true;
} else {
throw new Error(result.message || "Gagal update media sosial");
}
} catch (error) {
console.error("Error updating media sosial:", error);
toast.error(
error instanceof Error
? error.message
: "Terjadi kesalahan saat update media sosial"
);
return false;
} finally {
mediaSosial.update.loading = false;
}
}, },
},
}); });
const profileLandingPageState = proxy({ const profileLandingPageState = proxy({

View File

@@ -1,5 +1,6 @@
'use client'; 'use client';
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
import { apiFetchLogin } from '@/app/api/[auth]/_lib/api_fetch_auth';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core'; import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';

View File

@@ -1,7 +1,7 @@
// app/registrasi/page.tsx // app/registrasi/page.tsx
'use client'; 'use client';
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth'; import { apiFetchRegister } from '@/app/api/[auth]/_lib/api_fetch_auth';
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto'; import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
@@ -18,7 +18,6 @@ export default function Registrasi() {
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
const [agree, setAgree] = useState(false)
// Ambil data dari localStorage (dari login) // Ambil data dari localStorage (dari login)
useEffect(() => { useEffect(() => {
@@ -47,11 +46,6 @@ export default function Registrasi() {
return; return;
} }
if (!agree) {
toast.error("Anda harus menyetujui syarat dan ketentuan!");
return;
}
try { try {
setLoading(true); setLoading(true);
// ✅ Hanya kirim username & nomor → dapat kodeId // ✅ Hanya kirim username & nomor → dapat kodeId
@@ -98,8 +92,8 @@ export default function Registrasi() {
username.length > 0 && username.length < 5 username.length > 0 && username.length < 5
? 'Minimal 5 karakter!' ? 'Minimal 5 karakter!'
: username.includes(' ') : username.includes(' ')
? 'Tidak boleh ada spasi' ? 'Tidak boleh ada spasi'
: '' : ''
} }
required required
/> />
@@ -114,29 +108,9 @@ export default function Registrasi() {
</Box> </Box>
<Box pt="md"> <Box pt="md">
<Checkbox <Checkbox label="Saya menyetujui syarat dan ketentuan" defaultChecked />
checked={agree}
onChange={(e) => setAgree(e.currentTarget.checked)}
label={
<Text fz="sm">
Saya menyetujui{" "}
<a
href="/terms-of-service"
target="_blank"
style={{
color: colors["blue-button"],
textDecoration: "underline",
fontWeight: 500,
}}
>
syarat dan ketentuan
</a>
</Text>
}
/>
</Box> </Box>
<Box pt="xl"> <Box pt="xl">
<Button <Button
fullWidth fullWidth

View File

@@ -19,7 +19,7 @@ import { authStore } from '@/store/authStore';
export default function Validasi() { export default function Validasi() {
const router = useRouter(); const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null); const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState(''); const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -31,11 +31,11 @@ export default function Validasi() {
useEffect(() => { useEffect(() => {
const checkFlow = async () => { const checkFlow = async () => {
try { try {
const res = await fetch('/api/auth/get-flow', { const res = await fetch('/api/get-flow', {
credentials: 'include' credentials: 'include'
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
setIsRegistrationFlow(data.flow === 'register'); setIsRegistrationFlow(data.flow === 'register');
console.log('🔍 Flow detected from cookie:', data.flow); console.log('🔍 Flow detected from cookie:', data.flow);
@@ -45,7 +45,7 @@ export default function Validasi() {
setIsRegistrationFlow(false); setIsRegistrationFlow(false);
} }
}; };
checkFlow(); checkFlow();
}, []); }, []);
@@ -60,7 +60,7 @@ export default function Validasi() {
setKodeId(storedKodeId); setKodeId(storedKodeId);
const loadOtpData = async () => { const loadOtpData = async () => {
try { try {
const res = await fetch(`/api/auth/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`); const res = await fetch(`/api/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const result = await res.json(); const result = await res.json();
if (res.ok && result.data?.nomor) { if (res.ok && result.data?.nomor) {
@@ -110,8 +110,7 @@ export default function Validasi() {
return; return;
} }
// ✅ Verify OTP const verifyRes = await fetch('/api/verify-otp-register', {
const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }), body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
@@ -124,32 +123,26 @@ export default function Validasi() {
return; return;
} }
// ✅ Finalize registration const finalizeRes = await fetch('/api/finalize-registration', {
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, username, kodeId }), body: JSON.stringify({ nomor, username, kodeId }),
credentials: 'include' credentials: 'include'
}); });
const data = await finalizeRes.json(); const data = await finalizeRes.json();
// ✅ Check JSON response (bukan redirect) if (data.success || finalizeRes.redirected) {
if (data.success) { // ✅ Cleanup setelah registrasi sukses
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
await cleanupStorage(); await cleanupStorage();
window.location.href = '/waiting-room';
// ✅ Client-side redirect
setTimeout(() => {
window.location.href = '/waiting-room';
}, 1000);
} else { } else {
toast.error(data.message || 'Registrasi gagal'); toast.error(data.message || 'Registrasi gagal');
} }
}; };
const handleLoginVerification = async () => { const handleLoginVerification = async () => {
const loginRes = await fetch('/api/auth/verify-otp-login', { const loginRes = await fetch('/api/verify-otp-login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }), body: JSON.stringify({ nomor, otp, kodeId }),
@@ -204,10 +197,10 @@ export default function Validasi() {
localStorage.removeItem('auth_kodeId'); localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor'); localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username'); localStorage.removeItem('auth_username');
// Clear cookie // Clear cookie
try { try {
await fetch('/api/auth/clear-flow', { await fetch('/api/clear-flow', {
method: 'POST', method: 'POST',
credentials: 'include' credentials: 'include'
}); });
@@ -219,7 +212,7 @@ export default function Validasi() {
const handleResend = async () => { const handleResend = async () => {
if (!nomor) return; if (!nomor) return;
try { try {
const res = await fetch('/api/auth/resend', { const res = await fetch('/api/resend', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }), body: JSON.stringify({ nomor }),

View File

@@ -111,7 +111,7 @@ function EditKategoriBerita() {
{/* Form Wrapper */} {/* Form Wrapper */}
<Paper <Paper
w={{ base: '100%', md: '50%' }} w={{ base: '100%', md: '50%' }}
bg={colors['white-1']} bg={colors['white-1']}
p="lg" p="lg"
radius="md" radius="md"
shadow="sm" shadow="sm"

View File

@@ -1,3 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor'; import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
@@ -8,22 +10,19 @@ import {
Button, Button,
Group, Group,
Loader, Loader,
MultiSelect,
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title, Title
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
// Tipe form yang SESUAI dengan logika relasi (array ID) interface FasilitasKesehatanFormBase {
interface EditFasilitasKesehatanForm {
name: string; name: string;
informasiUmum: { informasiUmum: {
fasilitas: string; fasilitas: string;
@@ -31,92 +30,128 @@ interface EditFasilitasKesehatanForm {
jamOperasional: string; jamOperasional: string;
}; };
layananUnggulan: { content: string }; layananUnggulan: { content: string };
dokterdanTenagaMedis: string[]; // ← ARRAY ID dokterdanTenagaMedis: {
name: string;
specialist: string;
jadwal: string;
};
fasilitasPendukung: { content: string }; fasilitasPendukung: { content: string };
prosedurPendaftaran: { content: string }; prosedurPendaftaran: { content: string };
tarifDanLayanan: string[]; // ← ARRAY ID tarifDanLayanan: {
layanan: string;
tarif: string;
};
} }
function EditFasilitasKesehatan() { function EditFasilitasKesehatan() {
const state = useProxy(fasilitasKesehatanState.fasilitasKesehatan); const state = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
const dokterState = useProxy(fasilitasKesehatanState.dokter);
const tarifState = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter(); const router = useRouter();
const params = useParams<{ id: string }>(); const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<EditFasilitasKesehatanForm>({ const [formData, setFormData] = useState<FasilitasKesehatanFormBase>({
name: '', name: '',
informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' }, informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' },
layananUnggulan: { content: '' }, layananUnggulan: { content: '' },
dokterdanTenagaMedis: [], dokterdanTenagaMedis: { name: '', specialist: '', jadwal: '' },
fasilitasPendukung: { content: '' }, fasilitasPendukung: { content: '' },
prosedurPendaftaran: { content: '' }, prosedurPendaftaran: { content: '' },
tarifDanLayanan: [], tarifDanLayanan: { layanan: '', tarif: '' },
}); });
// Load data fasilitas & daftar dokter/tarif const [originalData, setOriginalData] = useState<FasilitasKesehatanFormBase>({
useShallowEffect(() => { name: '',
const loadAll = async () => { informasiUmum: { fasilitas: '', alamat: '', jamOperasional: '' },
const id = params?.id; layananUnggulan: { content: '' },
if (!id) return; dokterdanTenagaMedis: { name: '', specialist: '', jadwal: '' },
fasilitasPendukung: { content: '' },
prosedurPendaftaran: { content: '' },
tarifDanLayanan: { layanan: '', tarif: '' },
});
// Load dokter & tarif (untuk opsi MultiSelect) // Helper untuk update nested state
await Promise.all([ const updateForm = <K extends keyof FasilitasKesehatanFormBase>(
dokterState.findMany.load(), key: K,
tarifState.findMany.load(), value: FasilitasKesehatanFormBase[K]
]); ) => setFormData(prev => ({ ...prev, [key]: value }));
// Load data fasilitas const updateNested = <
await state.edit.load(id); K extends keyof FasilitasKesehatanFormBase,
const loaded = state.edit.form; N extends keyof FasilitasKesehatanFormBase[K]
if (loaded) { >(key: K, nestedKey: N, value: FasilitasKesehatanFormBase[K][N]) =>
setFormData({ setFormData(prev => ({
name: loaded.name, ...prev,
informasiUmum: loaded.informasiUmum, [key]: { ...prev[key] as object, [nestedKey]: value },
layananUnggulan: loaded.layananUnggulan, }));
dokterdanTenagaMedis: loaded.dokterdanTenagaMedis || [],
fasilitasPendukung: loaded.fasilitasPendukung,
prosedurPendaftaran: loaded.prosedurPendaftaran,
tarifDanLayanan: loaded.tarifDanLayanan || [],
});
}
};
loadAll(); const deepClone = (obj: any): any => {
}, [params?.id]); try {
return JSON.parse(JSON.stringify(obj));
const updateForm = <K extends keyof EditFasilitasKesehatanForm>( } catch (error) {
field: K, console.warn('Gagal deep clone dengan JSON fallback:', error);
value: EditFasilitasKesehatanForm[K] return obj; // fallback (berisiko shared reference)
) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleReset = () => {
const loaded = state.edit.form;
if (loaded) {
setFormData({
name: loaded.name,
informasiUmum: loaded.informasiUmum,
layananUnggulan: loaded.layananUnggulan,
dokterdanTenagaMedis: loaded.dokterdanTenagaMedis || [],
fasilitasPendukung: loaded.fasilitasPendukung,
prosedurPendaftaran: loaded.prosedurPendaftaran,
tarifDanLayanan: loaded.tarifDanLayanan || [],
});
toast.info('Form dikembalikan ke data awal');
} }
}; };
const handleSubmit = async (e: React.FormEvent) => { // Load data
e.preventDefault(); useEffect(() => {
const load = async () => {
const id = params?.id as string;
if (!id) return;
try {
await state.edit.load(id);
const loadedData = state.edit.form;
if (!loadedData) {
toast.error('Data tidak ditemukan');
return;
}
// Gunakan JSON fallback untuk deep clone
const clonedData = deepClone(loadedData) as FasilitasKesehatanFormBase;
setFormData(clonedData);
setOriginalData(clonedData);
} catch (err) {
console.error(err);
toast.error('Gagal memuat data fasilitas kesehatan');
}
};
load();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
informasiUmum:
{
fasilitas: originalData.informasiUmum.fasilitas,
alamat: originalData.informasiUmum.alamat,
jamOperasional: originalData.informasiUmum.jamOperasional
},
layananUnggulan: { content: originalData.layananUnggulan.content },
dokterdanTenagaMedis: {
name: originalData.dokterdanTenagaMedis.name,
specialist: originalData.dokterdanTenagaMedis.specialist,
jadwal: originalData.dokterdanTenagaMedis.jadwal
},
fasilitasPendukung: { content: originalData.fasilitasPendukung.content },
prosedurPendaftaran: { content: originalData.prosedurPendaftaran.content },
tarifDanLayanan: {
layanan: originalData.tarifDanLayanan.layanan,
tarif: originalData.tarifDanLayanan.tarif
},
});
toast.info("Form dikembalikan ke data awal");
};
// Submit
const handleSubmit = async () => {
try { try {
setIsSubmitting(true); setIsSubmitting(true);
state.edit.form = { ...state.edit.form, ...formData };
// Update state Valtio
state.edit.form = { ...formData };
const success = await state.edit.submit(); const success = await state.edit.submit();
if (success) { if (success) {
toast.success('Fasilitas kesehatan berhasil diperbarui!'); toast.success('Fasilitas kesehatan berhasil diperbarui!');
@@ -124,14 +159,14 @@ function EditFasilitasKesehatan() {
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
toast.error('Gagal memperbarui data'); toast.error('Terjadi kesalahan saat memperbarui data fasilitas kesehatan');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */} {/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
@@ -154,7 +189,7 @@ function EditFasilitasKesehatan() {
<Stack gap="md"> <Stack gap="md">
<TextInput <TextInput
label="Nama Fasilitas Kesehatan" label="Nama Fasilitas Kesehatan"
placeholder="Masukkan nama" placeholder="Masukkan nama fasilitas kesehatan"
value={formData.name} value={formData.name}
onChange={(e) => updateForm('name', e.target.value)} onChange={(e) => updateForm('name', e.target.value)}
required required
@@ -162,108 +197,118 @@ function EditFasilitasKesehatan() {
{/* Informasi Umum */} {/* Informasi Umum */}
<Box> <Box>
<Text fw="bold" mb={5}>Informasi Umum</Text> <Text fw="bold" mb={5}>
Informasi Umum
</Text>
<TextInput <TextInput
label="Fasilitas" label="Fasilitas"
value={formData.informasiUmum.fasilitas} value={formData.informasiUmum.fasilitas}
onChange={(e) => onChange={(e) => updateNested('informasiUmum', 'fasilitas', e.target.value)}
updateForm('informasiUmum', {
...formData.informasiUmum,
fasilitas: e.target.value,
})
}
/> />
<TextInput <TextInput
label="Alamat" label="Alamat"
value={formData.informasiUmum.alamat} value={formData.informasiUmum.alamat}
onChange={(e) => onChange={(e) => updateNested('informasiUmum', 'alamat', e.target.value)}
updateForm('informasiUmum', {
...formData.informasiUmum,
alamat: e.target.value,
})
}
/> />
<TextInput <TextInput
label="Jam Operasional" label="Jam Operasional"
value={formData.informasiUmum.jamOperasional} value={formData.informasiUmum.jamOperasional}
onChange={(e) => onChange={(e) => updateNested('informasiUmum', 'jamOperasional', e.target.value)}
updateForm('informasiUmum', {
...formData.informasiUmum,
jamOperasional: e.target.value,
})
}
/> />
</Box> </Box>
{/* Layanan Unggulan */} {/* Layanan Unggulan */}
<Box> <Box>
<Text fw="bold" mb={5}>Layanan Unggulan</Text> <Text fw="bold" mb={5}>
Layanan Unggulan
</Text>
<EditEditor <EditEditor
value={formData.layananUnggulan.content} value={formData.layananUnggulan.content}
onChange={(v) => updateForm('layananUnggulan', { content: v })} onChange={(v) => updateNested('layananUnggulan', 'content', v)}
/> />
</Box> </Box>
{/* Dokter & Tenaga Medis — MultiSelect */} {/* Dokter dan Tenaga Medis */}
<MultiSelect <Box>
label="Dokter & Tenaga Medis" <Text fw="bold" mb={5}>
placeholder="Pilih dokter/tenaga medis" Dokter dan Tenaga Medis
data={ </Text>
dokterState.findMany.data?.map((d) => ({ <TextInput
value: d.id, label="Nama Dokter"
label: `${d.name} (${d.specialist})`, value={formData.dokterdanTenagaMedis.name}
})) || [] onChange={(e) => updateNested('dokterdanTenagaMedis', 'name', e.target.value)}
} />
value={formData.dokterdanTenagaMedis} <TextInput
onChange={(val) => updateForm('dokterdanTenagaMedis', val)} label="Specialist"
searchable value={formData.dokterdanTenagaMedis.specialist}
clearable onChange={(e) =>
required updateNested('dokterdanTenagaMedis', 'specialist', e.target.value)
/> }
/>
<TextInput
label="Jadwal"
value={formData.dokterdanTenagaMedis.jadwal}
onChange={(e) => updateNested('dokterdanTenagaMedis', 'jadwal', e.target.value)}
/>
</Box>
{/* Fasilitas Pendukung */} {/* Fasilitas Pendukung */}
<Box> <Box>
<Text fw="bold" mb={5}>Fasilitas Pendukung</Text> <Text fw="bold" mb={5}>
Fasilitas Pendukung
</Text>
<EditEditor <EditEditor
value={formData.fasilitasPendukung.content} value={formData.fasilitasPendukung.content}
onChange={(v) => updateForm('fasilitasPendukung', { content: v })} onChange={(v) => updateNested('fasilitasPendukung', 'content', v)}
/> />
</Box> </Box>
{/* Prosedur Pendaftaran */} {/* Prosedur Pendaftaran */}
<Box> <Box>
<Text fw="bold" mb={5}>Prosedur Pendaftaran</Text> <Text fw="bold" mb={5}>
Prosedur Pendaftaran
</Text>
<EditEditor <EditEditor
value={formData.prosedurPendaftaran.content} value={formData.prosedurPendaftaran.content}
onChange={(v) => updateForm('prosedurPendaftaran', { content: v })} onChange={(v) => updateNested('prosedurPendaftaran', 'content', v)}
/> />
</Box> </Box>
{/* Tarif & Layanan — MultiSelect */} {/* Tarif dan Layanan */}
<MultiSelect <Box>
label="Tarif & Layanan" <Text fw="bold" mb={5}>
placeholder="Pilih layanan" Tarif dan Layanan
data={ </Text>
tarifState.findMany.data?.map((t) => ({ <TextInput
value: t.id, label="Tarif"
label: `${t.layanan} - ${t.tarif}`, value={formData.tarifDanLayanan.tarif}
})) || [] onChange={(e) => updateNested('tarifDanLayanan', 'tarif', e.target.value)}
} />
value={formData.tarifDanLayanan} <TextInput
onChange={(val) => updateForm('tarifDanLayanan', val)} label="Layanan"
searchable value={formData.tarifDanLayanan.layanan}
clearable onChange={(e) => updateNested('tarifDanLayanan', 'layanan', e.target.value)}
required />
/> </Box>
{/* Aksi */} {/* Tombol Simpan */}
<Group justify="right"> <Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={handleReset}> {/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
type="submit" onClick={handleSubmit}
radius="md" radius="md"
size="md"
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',
@@ -279,4 +324,4 @@ function EditFasilitasKesehatan() {
); );
} }
export default EditFasilitasKesehatan; export default EditFasilitasKesehatan;

View File

@@ -9,12 +9,6 @@ import {
Paper, Paper,
Skeleton, Skeleton,
Stack, Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text Text
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
@@ -114,79 +108,21 @@ function DetailFasilitasKesehatan() {
</Box> </Box>
<Box> <Box>
<Text fz="lg" fw="bold" mb="sm">Dokter & Tenaga Medis</Text> <Text fz="lg" fw="bold">Dokter & Tenaga Medis</Text>
{Array.isArray(data.dokterdantenagamedis) && data.dokterdantenagamedis.length > 0 ? ( <Text fz="md" fw="bold">Nama</Text>
<Box style={{ overflowX: 'auto', width: '100%' }}> <Text fz="md" c="dimmed">{data.dokterdantenagamedis?.name || '-'}</Text>
<Table striped highlightOnHover withTableBorder> <Text fz="md" fw="bold">Spesialis</Text>
<TableThead> <Text fz="md" c="dimmed">{data.dokterdantenagamedis?.specialist || '-'}</Text>
<TableTr> <Text fz="md" fw="bold">Jadwal</Text>
<TableTh style={{ whiteSpace: 'nowrap' }}>Nama</TableTh> <Text fz="md" c="dimmed">{data.dokterdantenagamedis?.jadwal || '-'}</Text>
<TableTh style={{ whiteSpace: 'nowrap' }}>Spesialis</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Jadwal</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Jam Operasional</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.dokterdantenagamedis.map((dokter) => (
<TableTr key={dokter.id}>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.name || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.specialist || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.jadwal || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{dokter.jamBukaOperasional} {dokter.jamTutupOperasional}
{dokter.jadwalLibur && (
<>
<br />
<Text span c="dimmed" fz="xs">
Libur: {dokter.jadwalLibur} ({dokter.jamBukaLibur}{dokter.jamTutupLibur})
</Text>
</>
)}
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
) : (
<Text c="dimmed">Tidak ada dokter atau tenaga medis terdaftar.</Text>
)}
</Box> </Box>
<Box mt="xl"> <Box>
<Text fz="lg" fw="bold" mb="sm">Tarif & Layanan</Text> <Text fz="lg" fw="bold">Tarif & Layanan</Text>
{Array.isArray(data.tarifdanlayanan) && data.tarifdanlayanan.length > 0 ? ( <Text fz="md" fw="bold">Layanan</Text>
<Box style={{ overflowX: 'auto', width: '100%' }}> <Text fz="md" c="dimmed">{data.tarifdanlayanan?.layanan || '-'}</Text>
<Table striped highlightOnHover withTableBorder> <Text fz="md" fw="bold">Tarif</Text>
<TableThead> <Text fz="md" c="dimmed">{data.tarifdanlayanan?.tarif || '-'}</Text>
<TableTr>
<TableTh style={{ whiteSpace: 'nowrap' }}>Layanan</TableTh>
<TableTh style={{ whiteSpace: 'nowrap' }}>Tarif</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{data.tarifdanlayanan.map((tarif) => (
<TableTr key={tarif.id}>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{tarif.layanan || '-'}
</TableTd>
<TableTd style={{ whiteSpace: 'normal', wordBreak: 'break-word' }}>
{tarif.tarif || '-'}
</TableTd>
</TableTr>
))}
</TableTbody>
</Table>
</Box>
) : (
<Text c="dimmed">Tidak ada tarif atau layanan terdaftar.</Text>
)}
</Box> </Box>
{/* Aksi */} {/* Aksi */}

View File

@@ -7,20 +7,19 @@ import {
Button, Button,
Group, Group,
Loader, Loader,
MultiSelect,
Paper, Paper,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Title Title
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react'; import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateFasilitasKesehatan() { function CreateFasilitasKesehatan() {
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan); const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.fasilitasKesehatan);
const router = useRouter(); const router = useRouter();
@@ -35,16 +34,23 @@ function CreateFasilitasKesehatan() {
jamOperasional: '', jamOperasional: '',
}, },
layananUnggulan: { layananUnggulan: {
content: '' content: '',
},
dokterdanTenagaMedis: {
name: '',
specialist: '',
jadwal: '',
}, },
dokterdanTenagaMedis: [] as string[],
fasilitasPendukung: { fasilitasPendukung: {
content: '', content: '',
}, },
prosedurPendaftaran: { prosedurPendaftaran: {
content: '', content: '',
}, },
tarifDanLayanan: [] as string[], tarifDanLayanan: {
layanan: '',
tarif: '',
},
}; };
}; };
@@ -64,11 +70,6 @@ function CreateFasilitasKesehatan() {
} }
}; };
useShallowEffect(() => {
fasilitasKesehatanState.dokter.findMany.load();
fasilitasKesehatanState.tarif.findMany.load();
}, []);
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}> <Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
{/* Header */} {/* Header */}
@@ -139,25 +140,31 @@ function CreateFasilitasKesehatan() {
/> />
</Box> </Box>
{/* Dokter dan Tenaga Medis */} {/* Dokter dan Tenaga Medis */}
<MultiSelect <Box>
label="Dokter & Tenaga Medis" <Text fz="md" fw="bold" mb={5}>Dokter dan Tenaga Medis</Text>
placeholder="Pilih dokter / tenaga medis" <TextInput
data={ label="Nama Dokter"
fasilitasKesehatanState.dokter.findMany.data?.map((item) => ({ placeholder="Masukkan nama dokter"
label: item.name, value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name}
value: item.id, onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.name = e.target.value)}
})) || [] required
} />
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis} <TextInput
onChange={(val: string[]) => { label="Spesialis"
stateFasilitasKesehatan.create.form.dokterdanTenagaMedis = val; placeholder="Masukkan spesialis"
}} value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist}
searchable onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.specialist = e.target.value)}
clearable required
required />
/> <TextInput
label="Jadwal"
placeholder="Masukkan jadwal"
value={stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal}
onChange={(e) => (stateFasilitasKesehatan.create.form.dokterdanTenagaMedis.jadwal = e.target.value)}
required
/>
</Box>
{/* Fasilitas Pendukung */} {/* Fasilitas Pendukung */}
<Box> <Box>
@@ -168,24 +175,6 @@ function CreateFasilitasKesehatan() {
/> />
</Box> </Box>
<MultiSelect
label="Layanan"
placeholder="Pilih layanan"
data={
fasilitasKesehatanState.tarif.findMany.data?.map((item) => ({
label: item.layanan,
value: item.id,
})) || []
}
value={stateFasilitasKesehatan.create.form.tarifDanLayanan} // string[]
onChange={(val: string[]) => {
stateFasilitasKesehatan.create.form.tarifDanLayanan = val;
}}
searchable
clearable
required
/>
{/* Prosedur Pendaftaran */} {/* Prosedur Pendaftaran */}
<Box> <Box>
<Text fz="md" fw="bold" mb={5}>Prosedur Pendaftaran</Text> <Text fz="md" fw="bold" mb={5}>Prosedur Pendaftaran</Text>
@@ -195,6 +184,24 @@ function CreateFasilitasKesehatan() {
/> />
</Box> </Box>
{/* Tarif dan Layanan */}
<Box>
<Text fz="md" fw="bold" mb={5}>Tarif dan Layanan</Text>
<TextInput
label="Tarif"
placeholder="Masukkan tarif"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif}
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.tarif = e.target.value)}
required
/>
<TextInput
label="Layanan"
placeholder="Masukkan layanan"
value={stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan}
onChange={(e) => (stateFasilitasKesehatan.create.form.tarifDanLayanan.layanan = e.target.value)}
required
/>
</Box>
{/* Submit */} {/* Submit */}
<Group justify="right"> <Group justify="right">

View File

@@ -1,241 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ import React from 'react';
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { TimeInput } from '@mantine/dates';
import { IconArrowBack, IconClock } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditDokterTenagaMedis() {
const state = useProxy(fasilitasKesehatanState.dokter);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState({
name: '',
specialist: '',
jadwal: '',
jadwalLibur: '',
jamBukaOperasional: '',
jamTutupOperasional: '',
jamBukaLibur: '',
jamTutupLibur: '',
});
const [originalData, setOriginalData] = useState({
name: '',
specialist: '',
jadwal: '',
jadwalLibur: '',
jamBukaOperasional: '',
jamTutupOperasional: '',
jamBukaLibur: '',
jamTutupLibur: '',
});
// Load data
useEffect(() => {
const load = async () => {
const id = params?.id as string;
if (!id) return;
try {
await state.update.load(id);
const loadedData = state.update.form;
if (!loadedData) {
toast.error('Data tidak ditemukan');
return;
}
setFormData(loadedData);
setOriginalData(loadedData);
} catch (err) {
console.error(err);
toast.error('Gagal memuat data fasilitas kesehatan');
}
};
load();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
specialist: originalData.specialist,
jadwal: originalData.jadwal,
jadwalLibur: originalData.jadwalLibur,
jamBukaOperasional: originalData.jamBukaOperasional,
jamTutupOperasional: originalData.jamTutupOperasional,
jamBukaLibur: originalData.jamBukaLibur,
jamTutupLibur: originalData.jamTutupLibur,
});
toast.info("Form dikembalikan ke data awal");
};
const refBuka = useRef<HTMLInputElement>(null);
const refTutup = useRef<HTMLInputElement>(null);
const refBukaLibur = useRef<HTMLInputElement>(null);
const refTutupLibur = useRef<HTMLInputElement>(null);
const picker = (ref: any) => (
<ActionIcon variant="subtle" color="gray" onClick={() => ref.current?.showPicker()}>
<IconClock size={16} stroke={1.5} />
</ActionIcon>
);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// Submit
const handleSubmit = async () => {
try {
setIsSubmitting(true);
state.update.form = { ...state.update.form, ...formData };
const success = await state.update.submit();
if (success) {
toast.success('Data berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis');
}
} catch (err) {
console.error(err);
toast.error('Terjadi kesalahan saat memperbarui data');
} finally {
setIsSubmitting(false);
}
};
function Page() {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <div>
{/* Header */} Page
<Group mb="md"> </div>
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Fasilitas Kesehatan
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Dokter"
placeholder="Masukkan nama dokter"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
required
/>
<TextInput
label="Jadwal"
placeholder="Masukkan jadwal"
value={formData.jadwal}
onChange={(e) => handleChange("jadwal", e.target.value)}
required
/>
<TextInput
label="Jadwal Libur"
placeholder="Masukkan jadwal libur"
value={formData.jadwalLibur}
onChange={(e) => handleChange("jadwalLibur", e.target.value)}
required
/>
<TimeInput
label="Jam Buka Operasional"
ref={refBuka}
rightSection={picker(refBuka)}
value={formData.jamBukaOperasional}
onChange={(e) => handleChange("jamBukaOperasional", e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Operasional"
ref={refTutup}
rightSection={picker(refTutup)}
value={formData.jamTutupOperasional}
onChange={(e) => handleChange("jamTutupOperasional", e.target.value)}
required
/>
<TimeInput
label="Jam Buka Hari Libur"
ref={refBukaLibur}
rightSection={picker(refBukaLibur)}
value={formData.jamBukaLibur}
onChange={(e) => handleChange("jamBukaLibur", e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Hari Libur"
ref={refTutupLibur}
rightSection={picker(refTutupLibur)}
value={formData.jamTutupLibur}
onChange={(e) => handleChange("jamTutupLibur", e.target.value)}
required
/>
{/* Tombol Simpan */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
); );
} }
export default EditDokterTenagaMedis; export default Page;

View File

@@ -1,165 +1,11 @@
'use client' import React from 'react';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Paper,
Skeleton,
Stack,
Text
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
function DetailDokterTenagaMedis() {
const params = useParams();
const router = useRouter();
const state = useProxy(fasilitasKesehatanState.dokter);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
useShallowEffect(() => {
state.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
state.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push(
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis'
);
}
};
if (!state.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = state.findUnique.data;
function Page() {
return ( return (
<Box py={10}> <div>
{/* Tombol Back */} Page
<Button </div>
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
{/* Wrapper Detail */}
<Paper
withBorder
w={{ base: '100%', md: '70%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Dokter & Tenaga Medis
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Nama Dokter</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="md" fw="bold">Specialist</Text>
<Text fz="md" c="dimmed">{data.specialist || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Jadwal</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jadwal || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jadwal Libur</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jadwalLibur || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Buka Operasional</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamBukaOperasional || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Tutup Operasional</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamTutupOperasional || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Buka Libur</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamBukaLibur || '-' }} />
</Box>
<Box>
<Text fz="lg" fw="bold">Jam Tutup Libur</Text>
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.jamTutupLibur || '-' }} />
</Box>
{/* Aksi */}
<Group gap="sm">
<Button
color="red"
onClick={() => {
setSelectedId(data.id);
setModalHapus(true);
}}
variant="light"
radius="md"
size="md"
>
<IconTrash size={20} />
</Button>
<Button
color="green"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/${data.id}/edit`
)
}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah anda yakin ingin menghapus dokter & tenaga medis ini?"
/>
</Box>
); );
} }
export default DetailDokterTenagaMedis; export default Page;

View File

@@ -1,184 +1,71 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { ActionIcon, Box, Button, Group, Loader, Paper, Stack, TextInput, Title } from '@mantine/core'; import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { TimeInput } from '@mantine/dates'; import { IconArrowBack } from '@tabler/icons-react';
import { IconArrowBack, IconClock } from '@tabler/icons-react'; import { useParams, useRouter } from 'next/navigation';
import { useRouter } from 'next/navigation';
import { useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
function CreateDokter() { function CreateDokter() {
const params = useParams()
const createState = useProxy(fasilitasKesehatanState.dokter) const createState = useProxy(fasilitasKesehatanState.dokter)
const router = useRouter(); const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => { const resetForm = () => {
createState.create.create.form = { createState.create.create.form = {
name: "", name: "",
specialist: "", specialist: "",
jadwal: "", jadwal: "",
jadwalLibur: "",
jamBukaOperasional: "",
jamTutupOperasional: "",
jamBukaLibur: "",
jamTutupLibur: "",
}; };
}; };
const refBuka = useRef<HTMLInputElement>(null);
const refTutup = useRef<HTMLInputElement>(null);
const refBukaLibur = useRef<HTMLInputElement>(null);
const refTutupLibur = useRef<HTMLInputElement>(null);
const picker = (ref: any) => (
<ActionIcon variant="subtle" color="gray" onClick={() => ref.current?.showPicker()}>
<IconClock size={16} stroke={1.5} />
</ActionIcon>
);
const handleSubmit = async () => { const handleSubmit = async () => {
try { await createState.create.create.create();
setIsSubmitting(true); resetForm();
await createState.create.create.create(); router.push(`/admin/kesehatan/fasilitas-kesehatan/${params?.id}/dokter-tenaga-medis`)
toast.success('Data berhasil disimpan');
resetForm();
router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis`)
} catch (error) {
console.error(error);
toast.error('Gagal menyimpan data');
} finally {
setIsSubmitting(false);
}
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}> <Box component="form" onSubmit={handleSubmit}>
{/* Header */} <Box mb={10}>
<Group mb="md"> <Button variant="subtle" onClick={() => router.back()}>
<Button <IconArrowBack color={colors['blue-button']} size={25} />
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button> </Button>
<Title order={4} ml="sm" c="dark"> </Box>
Tambah Data Dokter & Tenaga Medis
</Title>
</Group>
{/* Form */} <Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }}>
<Paper <Stack gap="xs">
w={{ base: '100%', md: '50%' }} <Title order={3}>Create Dokter</Title>
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput <TextInput
label={"Nama Dokter"} label={<Text fz="sm" fw="bold">Nama Dokter</Text>}
placeholder="Masukkan nama dokter" placeholder="masukkan nama dokter"
value={createState.create.create.form.name} value={createState.create.create.form.name}
onChange={(e) => (createState.create.create.form.name = e.target.value)} onChange={(e) => {
required createState.create.create.form.name = e.target.value;
}}
/> />
<Text fz="md" fw="bold">Specialist</Text>
{/* Informasi Umum */}
<TextInput <TextInput
label="Specialist" label={<Text fz="sm" fw="bold">Specialist</Text>}
placeholder="Masukkan specialist" placeholder="masukkan specialist"
value={createState.create.create.form.specialist} value={createState.create.create.form.specialist}
onChange={(e) => (createState.create.create.form.specialist = e.target.value)} onChange={(e) => {
required createState.create.create.form.specialist = e.target.value;
}}
/> />
<Box>
<TextInput <Text fz="md" fw="bold">Jadwal</Text>
label="Jadwal" <CreateEditor
placeholder="Masukkan jadwal" value={createState.create.create.form.jadwal}
value={createState.create.create.form.jadwal} onChange={(htmlContent) => {
onChange={(e) => (createState.create.create.form.jadwal = e.target.value)} createState.create.create.form.jadwal = htmlContent;
required
/>
<TextInput
label="Jadwal Libur"
placeholder="Masukkan jadwal libur"
value={createState.create.create.form.jadwalLibur}
onChange={(e) => (createState.create.create.form.jadwalLibur = e.target.value)}
required
/>
<TimeInput
label="Jam Buka Operasional"
ref={refBuka}
rightSection={picker(refBuka)}
value={createState.create.create.form.jamBukaOperasional}
onChange={(e) => (createState.create.create.form.jamBukaOperasional = e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Operasional"
ref={refTutup}
rightSection={picker(refTutup)}
value={createState.create.create.form.jamTutupOperasional}
onChange={(e) => (createState.create.create.form.jamTutupOperasional = e.target.value)}
required
/>
<TimeInput
label="Jam Buka Hari Libur"
ref={refBukaLibur}
rightSection={picker(refBukaLibur)}
value={createState.create.create.form.jamBukaLibur}
onChange={(e) => (createState.create.create.form.jamBukaLibur = e.target.value)}
required
/>
<TimeInput
label="Jam Tutup Hari Libur"
ref={refTutupLibur}
rightSection={picker(refTutupLibur)}
value={createState.create.create.form.jamTutupLibur}
onChange={(e) => (createState.create.create.form.jamTutupLibur = e.target.value)}
required
/>
{/* Submit */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}} }}
> />
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'} </Box>
</Button> <Button onClick={handleSubmit} bg={colors['blue-button']}>
</Group> Simpan
</Button>
</Stack> </Stack>
</Paper> </Paper>
</Box> </Box>

View File

@@ -1,12 +1,13 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react'; import { IconArrowBack, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '@/app/admin/(dashboard)/_com/header'; import HeaderSearch from '@/app/admin/(dashboard)/_com/header';
import JudulList from '@/app/admin/(dashboard)/_com/judulList';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import { useState } from 'react'; import { useState } from 'react';
@@ -17,7 +18,7 @@ function DokterTenagaMedis() {
return ( return (
<Box> <Box>
<Box mb={10}> <Box mb={10}>
<Button variant="subtle" onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan')}> <Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} /> <IconArrowBack color={colors['blue-button']} size={25} />
</Button> </Button>
</Box> </Box>
@@ -59,101 +60,49 @@ function ListDokterTenagaMedis({ search }: { search: string }) {
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper bg={colors['white-1']} p={'md'}>
{/* Judul + Tombol Tambah */} <Stack>
<Group justify="space-between" mb="md"> <JudulList
<Title order={4}>Daftar Dokter dan Tenaga Medis</Title> title='List Fasilitas Kesehatan'
<Button href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`}
leftSection={<IconPlus size={18} />} />
color="blue" <Box style={{ overflowX: "auto" }}>
variant="light" <Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}>
onClick={() => <TableThead>
router.push( <TableTr>
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create' <TableTh>Fasilitas Kesehatan</TableTh>
) <TableTh>Alamat</TableTh>
} <TableTh>Jam Operasional</TableTh>
> <TableTh>Detail</TableTh>
Tambah Baru </TableTr>
</Button> </TableThead>
</Group> <TableTbody>
{filteredData.map((item) => (
{/* Tabel */}
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Nama Dokter</TableTh>
<TableTh>Spesialis</TableTh>
<TableTh>Jadwal</TableTh>
<TableTh>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd>{item.name}</TableTd>
<TableTd>{item.specialist}</TableTd>
<TableTd> <TableTd>
<Box w={150}> <Text dangerouslySetInnerHTML={{ __html: item.jadwal }} />
<Text fw={500} truncate="end" lineClamp={1}>
{item.name}
</Text>
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}>
{item.specialist || '-'} <IconDeviceImacCog size={25} />
</Box>
</TableTd>
<TableTd>
<Box w={150}>
<Text dangerouslySetInnerHTML={{ __html: item.jadwal || '-' }} />
</Box>
</TableTd>
<TableTd>
<Button
variant="light"
color="blue"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/${item.id}`
)
}
>
<IconDeviceImacCog size={20} />
<Text ml={5}>Detail</Text>
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))}
) : ( </TableTbody>
<TableTr> </Table>
<TableTd colSpan={4}> </Box>
<Center py={20}> </Stack>
<Text c="dimmed">
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper> </Paper>
{/* Pagination */}
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => load(newPage)} // ini penting!
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
</Box> </Box>

View File

@@ -1,12 +1,9 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { import {
ActionIcon,
Box, Box,
Button, Button,
Center, Center,
Grid,
GridCol,
Group, Group,
Pagination, Pagination,
Paper, Paper,
@@ -19,52 +16,30 @@ import {
TableThead, TableThead,
TableTr, TableTr,
Text, Text,
TextInput, Title
Title,
Tooltip
} from '@mantine/core'; } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconCoin, IconDeviceImacCog, IconPlus, IconReportMedical, IconSearch } from '@tabler/icons-react'; import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import fasilitasKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '../../../_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
function FasilitasKesehatan() { function FasilitasKesehatan() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const router = useRouter()
return ( return (
<Box> <Box>
<Grid mb={10}> {/* Header Search */}
<GridCol span={{ base: 12, md: 8 }}> <HeaderSearch
<Title order={3}>Fasilitas Kesehatan</Title> title='Fasilitas Kesehatan'
</GridCol> placeholder='Cari nama, alamat, atau jam operasional...'
<GridCol span={{ base: 12, md: 4 }}> searchIcon={<IconSearch size={20} />}
<Group gap={"xs"}> value={search}
<Tooltip label="List Dokter" withArrow> onChange={(e) => setSearch(e.currentTarget.value)}
<ActionIcon onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis')} size="lg" radius="xl" color="green.6"> />
<IconReportMedical size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="List Tarif Layanan" withArrow>
<ActionIcon onClick={()=> router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan')} size="lg" radius="xl" color="blue.6">
<IconCoin size={20} />
</ActionIcon>
</Tooltip>
<Paper radius="lg" bg={colors['white-1']}>
<TextInput
radius="lg"
placeholder='Cari nama, alamat, atau jam operasional...'
leftSection={<IconSearch size={20} />}
w="133%"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
</Paper>
</Group>
</GridCol>
</Grid>
<ListFasilitasKesehatan search={search} /> <ListFasilitasKesehatan search={search} />
</Box> </Box>
@@ -79,7 +54,6 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
const { data, page, totalPages, loading, load } = stateFasilitasKesehatan.findMany; const { data, page, totalPages, loading, load } = stateFasilitasKesehatan.findMany;
useShallowEffect(() => { useShallowEffect(() => {
load(page, 10, search); load(page, 10, search);
}, [page, search]); }, [page, search]);
@@ -119,8 +93,8 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Fasilitas Kesehatan</TableTh> <TableTh>Fasilitas Kesehatan</TableTh>
<TableTh>Jumlah Dokter</TableTh> <TableTh>Dokter</TableTh>
<TableTh>Jumlah Layanan</TableTh> <TableTh>Layanan</TableTh>
<TableTh>Aksi</TableTh> <TableTh>Aksi</TableTh>
</TableTr> </TableTr>
</TableThead> </TableThead>
@@ -137,17 +111,13 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Box w={150}>
{item.dokterdantenagamedis?.length {item.dokterdantenagamedis?.name || '-'}
? `${item.dokterdantenagamedis.length} dokter`
: '-'}
</Box> </Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Box w={150}>
<Text truncate="end" lineClamp={1}> <Text truncate="end" lineClamp={1}>
{item.tarifdanlayanan?.length {item.tarifdanlayanan?.layanan || '-'}
? `${item.tarifdanlayanan.length} layanan`
: '-'}
</Text> </Text>
</Box> </Box>
</TableTd> </TableTd>
@@ -171,7 +141,7 @@ function ListFasilitasKesehatan({ search }: { search: string }) {
<TableTr> <TableTr>
<TableTd colSpan={4}> <TableTd colSpan={4}>
<Center py={20}> <Center py={20}>
<Text c="dimmed"> <Text color="dimmed">
Tidak ada fasilitas kesehatan yang cocok Tidak ada fasilitas kesehatan yang cocok
</Text> </Text>
</Center> </Center>

View File

@@ -1,173 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function EditTarifLayanan() {
const editState = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter();
const params = useParams();
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
tarif: '',
layanan: ''
});
const [formData, setFormData] = useState({
tarif: '',
layanan: ''
});
useEffect(() => {
const loadTarifLayanan = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await editState.update.load(id);
if (data) {
setFormData({
tarif: data.tarif || '',
layanan: data.layanan || '',
});
setOriginalData({
tarif: data.tarif || '',
layanan: data.layanan || '',
});
}
} catch (error) {
console.error('Error loading tarif layanan:', error);
toast.error('Gagal memuat data tarif layanan');
}
};
loadTarifLayanan();
}, [params?.id]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleResetForm = () => {
setFormData({
tarif: originalData.tarif,
layanan: originalData.layanan,
});
toast.info('Form dikembalikan ke data awal');
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// update global state hanya saat submit
editState.update.form = {
...editState.update.form,
tarif: formData.tarif,
layanan: formData.layanan,
};
await editState.update.submit();
toast.success('Tarif Layanan berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan');
} catch (error) {
console.error('Error updating tarif layanan:', error);
toast.error('Terjadi kesalahan saat memperbarui tarif layanan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Back Button + Title */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Tarif Layanan
</Title>
</Group>
{/* Form Wrapper */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
name="layanan"
label="Layanan"
placeholder="Masukkan nama layanan"
value={formData.layanan}
onChange={(e) => handleChange('layanan', e.target.value)}
required
/>
<TextInput
name="tarif"
label="Tarif"
placeholder="Masukkan tarif layanan"
value={formData.tarif}
onChange={(e) => handleChange('tarif', e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default EditTarifLayanan;

View File

@@ -1,119 +0,0 @@
'use client';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateTarifLayanan() {
const createState = useProxy(fasilitasKesehatanState.tarif);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
createState.create.form = {
tarif: '',
layanan: '',
};
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
await createState.create.create();
resetForm();
router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan');
} catch (error) {
console.error('Error creating tarif layanan:', error);
toast.error('Gagal menambahkan tarif layanan');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header dengan back button */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Tarif Layanan
</Title>
</Group>
{/* Form utama */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Layanan"
placeholder="Masukkan nama layanan"
value={createState.create.form.layanan || ''}
onChange={(e) => (createState.create.form.layanan = e.target.value)}
required
/>
<TextInput
label="Tarif"
placeholder="Masukkan tarif"
value={createState.create.form.tarif || ''}
onChange={(e) => (createState.create.form.tarif = e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateTarifLayanan;

View File

@@ -1,13 +1,13 @@
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Button, Center, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks'; import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react'; import { IconArrowBack, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '@/app/admin/(dashboard)/_com/header'; import HeaderSearch from '@/app/admin/(dashboard)/_com/header';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import JudulList from '@/app/admin/(dashboard)/_com/judulList';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan'; import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import { useState } from 'react'; import { useState } from 'react';
@@ -18,12 +18,12 @@ function TarifLayanan() {
return ( return (
<Box> <Box>
<Box mb={10}> <Box mb={10}>
<Button variant="subtle" onClick={() => router.push('/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan')}> <Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} /> <IconArrowBack color={colors['blue-button']} size={25} />
</Button> </Button>
</Box> </Box>
<HeaderSearch <HeaderSearch
title='Tarif dan Layanan' title='Dokter dan Tenaga Medis'
placeholder='pencarian' placeholder='pencarian'
searchIcon={<IconSearch size={20} />} searchIcon={<IconSearch size={20} />}
value={search} value={search}
@@ -35,11 +35,8 @@ function TarifLayanan() {
} }
function ListTarifLayanan({ search }: { search: string }) { function ListTarifLayanan({ search }: { search: string }) {
const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.tarif); const stateFasilitasKesehatan = useProxy(fasilitasKesehatanState.dokter)
const router = useRouter(); const router = useRouter();
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const { const {
data, data,
loading, loading,
@@ -52,15 +49,6 @@ function ListTarifLayanan({ search }: { search: string }) {
load(page, 10, search) load(page, 10, search)
}, [page, search]) }, [page, search])
const handleDelete = () => {
if (selectedId) {
stateFasilitasKesehatan.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
load(page, 10, search);
}
};
const filteredData = data || [] const filteredData = data || []
if (loading || !data) { if (loading || !data) {
@@ -72,116 +60,51 @@ function ListTarifLayanan({ search }: { search: string }) {
} }
return ( return (
<Box py={10}> <Box py={10}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper bg={colors['white-1']} p={'md'}>
{/* Judul + Tombol Tambah */} <Stack>
<Group justify="space-between" mb="md"> <JudulList
<Title order={4}>Daftar Tarif dan Layanan</Title> title='List Fasilitas Kesehatan'
<Button href={`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/dokter-tenaga-medis/create`}
leftSection={<IconPlus size={18} />} />
color="blue" <Box style={{ overflowX: "auto" }}>
variant="light" <Table striped withRowBorders withTableBorder style={{ minWidth: '700px' }}>
onClick={() => <TableThead>
router.push( <TableTr>
'/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/create' <TableTh>Fasilitas Kesehatan</TableTh>
) <TableTh>Alamat</TableTh>
} <TableTh>Jam Operasional</TableTh>
> <TableTh>Detail</TableTh>
Tambah Baru </TableTr>
</Button> </TableThead>
</Group> <TableTbody>
{filteredData.map((item) => (
{/* Tabel */}
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh>Layanan</TableTh>
<TableTh>Tarif</TableTh>
<TableTh>Edit</TableTh>
<TableTh>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}> <TableTr key={item.id}>
<TableTd>{item.name}</TableTd>
<TableTd>{item.specialist}</TableTd>
<TableTd> <TableTd>
<Box w={150}> <Text dangerouslySetInnerHTML={{ __html: item.jadwal }} />
{item.layanan || '-'}
</Box>
</TableTd> </TableTd>
<TableTd> <TableTd>
<Box w={150}> <Button onClick={() => router.push(`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/${item.id}`)}>
<Text fw={500} truncate="end" lineClamp={1}> <IconDeviceImacCog size={25} />
{item.tarif}
</Text>
</Box>
</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/fasilitas_kesehatan/tarif-layanan/${item.id}`
)
}
>
<IconEdit size={18} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateFasilitasKesehatan.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
>
<IconTrash size={18} />
</Button> </Button>
</TableTd> </TableTd>
</TableTr> </TableTr>
)) ))}
) : ( </TableTbody>
<TableTr> </Table>
<TableTd colSpan={4}> </Box>
<Center py={20}> </Stack>
<Text c="dimmed">
Tidak ada fasilitas kesehatan yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper> </Paper>
{/* Pagination */}
<Center> <Center>
<Pagination <Pagination
value={page} value={page}
onChange={(newPage) => { onChange={(newPage) => load(newPage)} // ini penting!
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages} total={totalPages}
mt="md" mt="md"
mb="md" mb="md"
color="blue"
radius="md"
/> />
</Center> </Center>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus tarif layanan ini?"
/>
</Box> </Box>
) )
} }

View File

@@ -361,7 +361,6 @@ function CreateAPBDes() {
data={[ data={[
{ value: 'pendapatan', label: 'Pendapatan' }, { value: 'pendapatan', label: 'Pendapatan' },
{ value: 'belanja', label: 'Belanja' }, { value: 'belanja', label: 'Belanja' },
{ value: 'pembiayaan', label: 'Pembiayaan' },
]} ]}
value={newItem.level === 1 ? null : newItem.tipe} value={newItem.level === 1 ? null : newItem.tipe}
onChange={(val) => setNewItem({ ...newItem, tipe: val as any })} onChange={(val) => setNewItem({ ...newItem, tipe: val as any })}

View File

@@ -1,12 +0,0 @@
export const sosmedMap = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};

View File

@@ -1,7 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import SelectSocialMediaEdit from '@/app/admin/(dashboard)/_com/selectSocialMediaEdit';
import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile'; import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
@@ -16,7 +14,7 @@ import {
Text, Text,
TextInput, TextInput,
Title, Title,
Loader, Loader
} from '@mantine/core'; } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone'; import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react'; import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -25,45 +23,15 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
type SosmedKey =
| 'none'
| 'facebook'
| 'instagram'
| 'tiktok'
| 'youtube'
| 'whatsapp'
| 'gmail'
| 'telegram'
| 'x'
| 'telephone'
| 'custom';
const sosmedMap: Record<SosmedKey, { label: string; src: string | null }> = {
none: { label: "None", src: '/no-image.jpg' },
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
function EditMediaSosial() { function EditMediaSosial() {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const router = useRouter(); const router = useRouter();
const params = useParams(); const params = useParams();
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
name: '', name: '',
icon: '',
iconUrl: '', iconUrl: '',
imageId: '', imageId: '',
}); });
@@ -71,14 +39,13 @@ function EditMediaSosial() {
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({ const [originalData, setOriginalData] = useState({
name: '', name: "",
icon: '', iconUrl: "",
iconUrl: '', imageId: "",
imageId: '', imageUrl: "",
imageUrl: '',
}); });
// Load Data by ID // Load data by ID
useEffect(() => { useEffect(() => {
const id = params?.id as string; const id = params?.id as string;
if (!id) return; if (!id) return;
@@ -87,97 +54,81 @@ function EditMediaSosial() {
try { try {
const data = await stateMediaSosial.update.load(id); const data = await stateMediaSosial.update.load(id);
if (!data) return; if (data) {
// isi form awal
const newForm = {
name: data.name || "",
iconUrl: data.iconUrl || "",
imageId: data.imageId || "",
};
setFormData(newForm);
// Tentukan default/custom icon // simpan juga versi original
// Tentukan default/custom icon setOriginalData({
if (data.imageId) { ...newForm,
setSelectedSosmed('custom'); imageUrl: data.image?.link || "",
} else { });
// ✅ Gunakan langsung data.icon jika ada dan valid
if (data.icon && sosmedMap[data.icon as SosmedKey]) { setPreviewImage(data.image?.link || null);
setSelectedSosmed(data.icon as SosmedKey);
} else {
setSelectedSosmed('none'); // fallback
}
} }
} catch (error) {
const newForm = { console.error('Error loading media sosial:', error);
name: data.name || '', toast.error(
icon: data.icon || '', error instanceof Error ? error.message : 'Gagal mengambil data media sosial'
iconUrl: data.iconUrl || '', );
imageId: data.imageId || '',
};
setFormData(newForm);
setOriginalData({
...newForm,
imageUrl: data.image?.link || '',
});
setPreviewImage(data.image?.link || null);
} catch {
toast.error('Gagal mengambil data media sosial');
} }
}; };
loadData(); loadData();
}, [params?.id]); }, [params?.id]);
const handleChange = (field: keyof typeof formData, value: string) => { const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// update global state hanya saat submit
stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData }; stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData };
if (file) { if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
file,
name: file.name,
});
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) return toast.error('Gagal upload gambar');
toast.error('Gagal upload gambar');
return;
}
stateMediaSosial.update.form.imageId = uploaded.id; stateMediaSosial.update.form.imageId = uploaded.id;
} }
// 🚨 Tambahkan ini untuk debugging
console.log("Data yang akan dikirim ke backend:", stateMediaSosial.update.form);
await stateMediaSosial.update.update(); await stateMediaSosial.update.update();
toast.success('Media sosial berhasil diperbarui!'); toast.success('Media sosial berhasil diperbarui!');
router.push('/admin/landing-page/profil/media-sosial'); router.push('/admin/landing-page/profil/media-sosial');
} catch (error) { } catch (error) {
console.error("Error di handleSubmit:", error); // 🚨 Tambahkan ini juga console.error('Error updating media sosial:', error);
toast.error('Terjadi kesalahan saat memperbarui media sosial'); toast.error('Terjadi kesalahan saat memperbarui media sosial');
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
// ✅ Tombol Batal → balikin ke data original
const handleResetForm = () => { const handleResetForm = () => {
setFormData({ setFormData({
name: originalData.name, name: originalData.name,
icon: originalData.icon,
iconUrl: originalData.iconUrl, iconUrl: originalData.iconUrl,
imageId: originalData.imageId, imageId: originalData.imageId,
}); });
setPreviewImage(originalData.imageUrl || null); setPreviewImage(originalData.imageUrl || null);
setFile(null); setFile(null);
toast.info('Form dikembalikan ke data awal'); toast.info("Form dikembalikan ke data awal");
}; };
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box
px={{ base: 'sm', md: 'lg' }}
py="md"
>
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
@@ -196,119 +147,80 @@ function EditMediaSosial() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Upload / Icon */} {/* Upload Gambar */}
<Box> <Box>
<Text fw="bold" fz="sm" mb={6}> <Text fw="bold" fz="sm" mb={6}>
Icon / Gambar Media Sosial Gambar Program Inovasi
</Text> </Text>
<Dropzone
{/* Custom Upload */} onDrop={(files) => {
{/* PILIH ICON */} const selectedFile = files[0];
<SelectSocialMediaEdit if (selectedFile) {
value={selectedSosmed} setFile(selectedFile);
onChange={(key) => { setPreviewImage(URL.createObjectURL(selectedFile));
setSelectedSosmed(key);
if (key === 'custom') {
// custom → gunakan Dropzone
setFormData((prev) => ({
...prev,
icon: '',
imageId: '',
}));
return;
} }
// default → pakai icon bawaan
setFormData((prev) => ({
...prev,
icon: key, // <-- simpan 'facebook', bukan path
imageId: '',
}));
}} }}
/> onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{selectedSosmed === 'custom' ? ( {/* ✅ Preview gambar + tombol X */}
<> {previewImage && (
<Dropzone <Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
handleChange('imageId', '');
}
}}
onReject={() => toast.error('File tidak valid')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack align="center" gap="xs">
<Text fw={500}>Seret gambar atau klik untuk pilih</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format: .png, .jpg, .jpeg, .webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setFile(null);
setPreviewImage(null);
handleChange('imageId', '');
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</>
) : (
// Default icon
<Box mt="xs">
<Image <Image
src={sosmedMap[selectedSosmed].src || ''} src={previewImage}
alt="Icon bawaan" alt="Preview Gambar"
width={40}
height={40}
radius="md" radius="md"
style={{ border: '1px solid #ddd', padding: 4, background: '#fff' }} style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/> />
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box> </Box>
)} )}
</Box> </Box>
@@ -325,17 +237,25 @@ function EditMediaSosial() {
{/* Link Media Sosial */} {/* Link Media Sosial */}
<TextInput <TextInput
label="Link Media Sosial / Nomor Telepon" label="Link Media Sosial / Nomor Telepon"
placeholder="Masukkan link atau nomor telepon" placeholder="Masukkan link media sosial atau nomor telepon"
value={formData.iconUrl} value={formData.iconUrl}
onChange={(e) => handleChange('iconUrl', e.target.value)} onChange={(e) => handleChange('iconUrl', e.target.value)}
required required
/> />
<Group justify="right"> <Group justify="right">
<Button variant="outline" color="gray" radius="md" size="md" onClick={handleResetForm}> {/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal Batal
</Button> </Button>
{/* Tombol Simpan */}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
radius="md" radius="md"

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus'; import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile'; import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page/profile';
@@ -9,7 +8,6 @@ import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import { sosmedMap } from '../../_lib/sosmed';
function DetailMediaSosial() { function DetailMediaSosial() {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
@@ -18,14 +16,6 @@ function DetailMediaSosial() {
const params = useParams(); const params = useParams();
const router = useRouter(); const router = useRouter();
const getIconSource = (item: any) => {
if (item.image?.link) return item.image.link;
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) {
return sosmedMap[item.icon as keyof typeof sosmedMap].src;
}
return null;
};
useShallowEffect(() => { useShallowEffect(() => {
stateMediaSosial.findUnique.load(params?.id as string); stateMediaSosial.findUnique.load(params?.id as string);
}, []); }, []);
@@ -87,47 +77,46 @@ function DetailMediaSosial() {
<Box> <Box>
<Text fz="lg" fw="bold">Gambar</Text> <Text fz="lg" fw="bold">Gambar</Text>
{(() => { {data.image?.link ? (
const src = getIconSource(data); <Image
src={data.image.link}
alt={data.name || 'Gambar Media Sosial'}
w="100%"
maw={120} // max width biar tidak keluar layar
h="auto"
radius="md"
fit="cover"
loading="lazy"
/>
if (src) { ) : (
return ( <Text fz="sm" c="dimmed">Tidak ada gambar</Text>
<Image )}
loading="lazy"
src={src}
alt={data.name}
fit={data.image?.link ? "cover" : "contain"}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box> </Box>
<Group gap="sm"> <Group gap="sm">
<Button <Button
color="red" color="red"
onClick={() => { onClick={() => {
setSelectedId(data.id); setSelectedId(data.id);
setModalHapus(true); setModalHapus(true);
}} }}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconTrash size={20} /> <IconTrash size={20} />
</Button> </Button>
<Button <Button
color="green" color="green"
onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${data.id}/edit`)} onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${data.id}/edit`)}
variant="light" variant="light"
radius="md" radius="md"
size="md" size="md"
> >
<IconEdit size={20} /> <IconEdit size={20} />
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
</Paper> </Paper>

View File

@@ -1,6 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client'; 'use client';
import colors from '@/con/colors'; import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch'; import ApiFetch from '@/lib/api-fetch';
import { import {
@@ -23,41 +22,10 @@ import { useEffect, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import profileLandingPageState from '../../../../_state/landing-page/profile'; import profileLandingPageState from '../../../../_state/landing-page/profile';
import SelectSosialMedia from '@/app/admin/(dashboard)/_com/selectSocialMedia';
// ⭐ Tambah type SosmedKey
type SosmedKey =
| 'facebook'
| 'instagram'
| 'tiktok'
| 'youtube'
| 'whatsapp'
| 'gmail'
| 'telegram'
| 'x'
| 'telephone'
| 'custom';
// ⭐ mapping icon sosmed bawaan
const sosmedMap: Record<SosmedKey, { label: string; src: string | null }> = {
facebook: { label: 'Facebook', src: '/assets/images/sosmed/facebook.png' },
instagram: { label: 'Instagram', src: '/assets/images/sosmed/instagram.png' },
tiktok: { label: 'Tiktok', src: '/assets/images/sosmed/tiktok.png' },
youtube: { label: 'YouTube', src: '/assets/images/sosmed/youtube.png' },
whatsapp: { label: 'WhatsApp', src: '/assets/images/sosmed/whatsapp.png' },
gmail: { label: 'Gmail', src: '/assets/images/sosmed/gmail.png' },
telegram: { label: 'Telegram', src: '/assets/images/sosmed/telegram.png' },
x: { label: 'X (Twitter)', src: '/assets/images/sosmed/x-twitter.png' },
telephone: { label: 'Telephone', src: '/assets/images/sosmed/telephone-call.png' },
custom: { label: 'Custom Icon', src: null },
};
export default function CreateMediaSosial() { export default function CreateMediaSosial() {
const router = useRouter(); const router = useRouter();
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial); const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const [selectedSosmed, setSelectedSosmed] = useState<SosmedKey>('facebook');
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -71,34 +39,16 @@ export default function CreateMediaSosial() {
name: '', name: '',
imageId: '', imageId: '',
iconUrl: '', iconUrl: '',
icon: ''
}; };
setFile(null);
setPreviewImage(null); setPreviewImage(null);
setSelectedSosmed('facebook'); setFile(null);
}; };
const handleSubmit = async () => { const handleSubmit = async () => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// ──────────────── ⭐ CASE 1: PAKAI ICON DEFAULT ────────────────
if (selectedSosmed !== 'custom') {
stateMediaSosial.create.form.imageId = null;
stateMediaSosial.create.form.icon = sosmedMap[selectedSosmed].src!;
await stateMediaSosial.create.create();
resetForm();
router.push('/admin/landing-page/profil/media-sosial');
return;
}
// ──────────────── ⭐ CASE 2: CUSTOM ICON → WAJIB UPLOAD ────────────────
if (!file) { if (!file) {
toast.warn('Silakan upload icon custom terlebih dahulu'); return toast.warn('Silakan pilih file gambar terlebih dahulu');
return;
} }
const res = await ApiFetch.api.fileStorage.create.post({ const res = await ApiFetch.api.fileStorage.create.post({
@@ -109,12 +59,10 @@ export default function CreateMediaSosial() {
const uploaded = res.data?.data; const uploaded = res.data?.data;
if (!uploaded?.id) { if (!uploaded?.id) {
toast.error('Gagal mengunggah icon custom'); return toast.error('Gagal mengunggah gambar, silakan coba lagi');
return;
} }
stateMediaSosial.create.form.imageId = uploaded.id; stateMediaSosial.create.form.imageId = uploaded.id;
stateMediaSosial.create.form.icon = null;
await stateMediaSosial.create.create(); await stateMediaSosial.create.create();
@@ -130,7 +78,6 @@ export default function CreateMediaSosial() {
return ( return (
<Box px={{ base: 'sm', md: 'lg' }} py="md"> <Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<Group mb="md"> <Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md"> <Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} /> <IconArrowBack color={colors['blue-button']} size={24} />
@@ -149,110 +96,112 @@ export default function CreateMediaSosial() {
style={{ border: '1px solid #e0e0e0' }} style={{ border: '1px solid #e0e0e0' }}
> >
<Stack gap="md"> <Stack gap="md">
{/* Select Sosmed */} <Box>
<SelectSosialMedia value={selectedSosmed} onChange={setSelectedSosmed} /> <Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{/* Custom icon uploader */} {/* ✅ Preview gambar + tombol X */}
{selectedSosmed === 'custom' && ( {previewImage && (
<Box> <Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Text fw="bold" fz="sm" mb={6}> <Image
Upload Custom Icon src={previewImage}
</Text> alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
<Dropzone {/* Tombol hapus (pojok kanan atas) */}
onDrop={(files) => { <ActionIcon
const selectedFile = files[0]; variant="filled"
if (selectedFile) { color="red"
setFile(selectedFile); radius="xl"
setPreviewImage(URL.createObjectURL(selectedFile)); size="sm"
} pos="absolute"
}} top={5}
onReject={() => toast.error('File tidak valid')} right={5}
maxSize={5 * 1024 ** 2} onClick={() => {
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }} setPreviewImage(null);
radius="md" setFile(null);
p="xl" }}
> style={{
<Group justify="center" gap="xl" mih={180}> boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
<Dropzone.Accept> }}
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} /> >
</Dropzone.Accept> <IconX size={14} />
<Dropzone.Reject> </ActionIcon>
<IconX size={48} color="red" stroke={1.5} /> </Box>
</Dropzone.Reject> )}
<Dropzone.Idle> </Box>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack align="center" gap="xs">
<Text fw={500}>Seret gambar atau klik untuk pilih</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setFile(null);
setPreviewImage(null);
}}
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
)}
{/* Input name */}
<TextInput <TextInput
label="Nama Media Sosial" label="Nama Media Sosial / Kontak"
placeholder="Masukkan nama media sosial" placeholder="Masukkan nama media sosial atau kontak"
value={stateMediaSosial.create.form.name ?? ''} value={stateMediaSosial.create.form.name || ''}
onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)} onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)}
required required
/> />
{/* Input link */}
<TextInput <TextInput
label="Link / Kontak" label="Link Media Sosial / Nomor Telepon"
placeholder="Masukkan link atau nomor" placeholder="Masukkan link media sosial atau nomor telepon"
value={stateMediaSosial.create.form.iconUrl ?? ''} value={stateMediaSosial.create.form.iconUrl || ''}
onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)} onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)}
required required
/> />
{/* Actions */}
<Group justify="right"> <Group justify="right">
<Button variant="outline" color="gray" radius="md" onClick={resetForm}> <Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset Reset
</Button> </Button>
<Button <Button
radius="md"
onClick={handleSubmit} onClick={handleSubmit}
radius="md"
size="md"
style={{ style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`, background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff', color: '#fff',

View File

@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client' 'use client'
import colors from '@/con/colors'; import colors from '@/con/colors';
import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core'; import { Box, Button, Center, Group, Image, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
@@ -9,7 +8,6 @@ import { useState } from 'react';
import { useProxy } from 'valtio/utils'; import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header'; import HeaderSearch from '../../../_com/header';
import profileLandingPageState from '../../../_state/landing-page/profile'; import profileLandingPageState from '../../../_state/landing-page/profile';
import { sosmedMap } from '../_lib/sosmed';
function MediaSosial() { function MediaSosial() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
@@ -31,14 +29,6 @@ function ListMediaSosial({ search }: { search: string }) {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial) const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial)
const router = useRouter(); const router = useRouter();
const getIconSource = (item: any) => {
if (item.image?.link) return item.image.link;
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) {
return sosmedMap[item.icon as keyof typeof sosmedMap].src;
}
return null;
};
const { const {
data, data,
page, page,
@@ -66,9 +56,9 @@ function ListMediaSosial({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md"> <Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md"> <Group justify="space-between" mb="md">
<Title order={4}>Daftar Media Sosial</Title> <Title order={4}>Daftar Media Sosial</Title>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}> <Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}>
Tambah Baru Tambah Baru
</Button> </Button>
</Group> </Group>
<Box style={{ overflowX: "auto" }}> <Box style={{ overflowX: "auto" }}>
<Table highlightOnHover> <Table highlightOnHover>
@@ -87,26 +77,13 @@ function ListMediaSosial({ search }: { search: string }) {
<TableTd style={{ width: '25%', }}> <TableTd style={{ width: '25%', }}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text> <Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</TableTd> </TableTd>
<TableTd style={{ width: '20%' }}> <TableTd style={{ width: '20%', }}>
<Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden' }}> <Box w={50} h={50} style={{ borderRadius: 8, overflow: 'hidden', }}>
{item.image?.link ? (
{(() => { <Image loading='lazy' src={item.image.link} alt={item.name} fit="cover" />
const src = getIconSource(item); ) : (
<Box bg={colors['blue-button']} w="100%" h="100%" />
if (src) { )}
return (
<Image
loading="lazy"
src={src}
alt={item.name}
fit={item.image?.link ? "cover" : "contain"}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</Box> </Box>
</TableTd> </TableTd>
<TableTd style={{ width: '20%', }}> <TableTd style={{ width: '20%', }}>

View File

@@ -93,7 +93,6 @@ function ListUser({ search }: { search: string }) {
const success = await stateUser.update.submit({ const success = await stateUser.update.submit({
id: userId, id: userId,
roleId: newRoleId, roleId: newRoleId,
}); });
if (success) { if (success) {
@@ -137,10 +136,9 @@ function ListUser({ search }: { search: string }) {
} }
}; };
const filteredData = (data || []).filter((item) => { const filteredData = (data || []).filter(
return item.roleId !== "0" && item.roleId !== "1"; (item) => item.roleId !== "0" // asumsikan id role SUPERADMIN = "0"
}); );
if (loading || !data) { if (loading || !data) {
return ( return (
@@ -185,7 +183,7 @@ function ListUser({ search }: { search: string }) {
<Select <Select
placeholder="Pilih role" placeholder="Pilih role"
data={stateRole.findMany.data data={stateRole.findMany.data
.filter(r => r.id !== "0" && r.id !== "1") // ❌ Sembunyikan SUPERADMIN dan DEVELOPER .filter(r => r.id !== "0") // ❌ Sembunyikan SUPERADMIN
.map(r => ({ .map(r => ({
label: r.name, label: r.name,
value: r.id, value: r.id,

View File

@@ -1,399 +1,3 @@
// 'use client'
// import colors from "@/con/colors";
// import { authStore } from "@/store/authStore";
// import {
// ActionIcon,
// AppShell,
// AppShellHeader,
// AppShellMain,
// AppShellNavbar,
// Burger,
// Center,
// Flex,
// Group,
// Image,
// Loader,
// NavLink,
// ScrollArea,
// Text,
// Tooltip,
// rem
// } from "@mantine/core";
// import { useDisclosure } from "@mantine/hooks";
// import {
// IconChevronLeft,
// IconChevronRight,
// IconLogout2
// } from "@tabler/icons-react";
// import _ from "lodash";
// import Link from "next/link";
// import { useRouter, useSelectedLayoutSegments } from "next/navigation";
// import { useEffect, useState } from "react";
// // import { useSnapshot } from "valtio";
// import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
// export default function Layout({ children }: { children: React.ReactNode }) {
// const [opened, { toggle }] = useDisclosure();
// const [loading, setLoading] = useState(true);
// const [isLoggingOut, setIsLoggingOut] = useState(false);
// const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
// const router = useRouter();
// const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// // const { user } = useSnapshot(authStore);
// // console.log("Current user in store:", user);
// // ✅ FIX: Selalu fetch user data setiap kali komponen mount
// useEffect(() => {
// const fetchUser = async () => {
// try {
// const res = await fetch('/api/auth/me');
// const data = await res.json();
// if (data.user) {
// // ✅ Check if user is NOT active → redirect to waiting room
// if (!data.user.isActive) {
// authStore.setUser(null);
// router.replace('/waiting-room');
// return;
// }
// // ✅ Fetch menuIds
// const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
// const menuData = await menuRes.json();
// const menuIds = menuData.success && Array.isArray(menuData.menuIds)
// ? [...menuData.menuIds]
// : null;
// // ✅ Set user dengan menuIds yang fresh
// authStore.setUser({
// id: data.user.id,
// name: data.user.name,
// roleId: Number(data.user.roleId),
// menuIds,
// isActive: data.user.isActive
// });
// // ✅ TAMBAHKAN INI: Redirect ke dashboard sesuai roleId
// const currentPath = window.location.pathname;
// const expectedPath = getRedirectPath(Number(data.user.roleId));
// // Jika user di halaman /admin tapi bukan di path yang sesuai roleId
// if (currentPath === '/admin' || !currentPath.startsWith(expectedPath)) {
// router.replace(expectedPath);
// }
// } else {
// authStore.setUser(null);
// router.replace('/login');
// }
// } catch (error) {
// console.error('Gagal memuat data pengguna:', error);
// authStore.setUser(null);
// router.replace('/login');
// } finally {
// setLoading(false);
// }
// };
// fetchUser();
// }, [router]);
// // ✅ Fungsi helper untuk get redirect path
// const getRedirectPath = (roleId: number): string => {
// switch (roleId) {
// case 0: // DEVELOPER
// case 1: // SUPERADMIN
// case 2: // ADMIN_DESA
// return '/admin/landing-page/profil/program-inovasi';
// case 3: // ADMIN_KESEHATAN
// return '/admin/kesehatan/posyandu';
// case 4: // ADMIN_PENDIDIKAN
// return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
// default:
// return '/admin';
// }
// };
// if (loading) {
// return (
// <AppShell>
// <AppShellMain>
// <Center h="100vh">
// <Loader />
// </Center>
// </AppShellMain>
// </AppShell>
// );
// }
// // ✅ Ambil menu berdasarkan roleId dan menuIds
// const currentNav = authStore.user
// ? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
// : [];
// const handleLogout = async () => {
// try {
// setIsLoggingOut(true);
// // ✅ Panggil API logout untuk clear session di server
// const response = await fetch('/api/auth/logout', { method: 'POST' });
// const result = await response.json();
// if (result.success) {
// // Clear user data dari store
// authStore.setUser(null);
// // Clear localStorage
// localStorage.removeItem('auth_nomor');
// localStorage.removeItem('auth_kodeId');
// // Force reload untuk reset semua state
// window.location.href = '/login';
// } else {
// console.error('Logout failed:', result.message);
// // Tetap redirect meskipun gagal
// authStore.setUser(null);
// window.location.href = '/login';
// }
// } catch (error) {
// console.error('Error during logout:', error);
// // Tetap clear store dan redirect jika error
// authStore.setUser(null);
// window.location.href = '/login';
// } finally {
// setIsLoggingOut(false);
// }
// };
// return (
// <AppShell
// suppressHydrationWarning
// header={{ height: 64 }}
// navbar={{
// width: { base: 260, sm: 280, lg: 300 },
// breakpoint: 'sm',
// collapsed: {
// mobile: !opened,
// desktop: !desktopOpened,
// },
// }}
// padding="md"
// >
// <AppShellHeader
// style={{
// background: "linear-gradient(90deg, #ffffff, #f9fbff)",
// borderBottom: `1px solid ${colors["blue-button"]}20`,
// padding: '0 16px',
// }}
// px={{ base: 'sm', sm: 'md' }}
// py={{ base: 'xs', sm: 'sm' }}
// >
// <Group w="100%" h="100%" justify="space-between" wrap="nowrap">
// <Flex align="center" gap="sm">
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={{ base: 32, sm: 40 }}
// h={{ base: 32, sm: 40 }}
// radius="md"
// loading="lazy"
// style={{
// minWidth: '32px',
// height: 'auto',
// }}
// />
// <Text
// fw={700}
// c={colors["blue-button"]}
// fz={{ base: 'md', sm: 'xl' }}
// >
// Admin Darmasaba
// </Text>
// </Flex>
// <Group gap="xs">
// {!desktopOpened && (
// <Tooltip label="Buka Navigasi" position="bottom" withArrow>
// <ActionIcon
// variant="light"
// radius="xl"
// size="lg"
// onClick={toggleDesktop}
// color={colors["blue-button"]}
// >
// <IconChevronRight />
// </ActionIcon>
// </Tooltip>
// )}
// <Burger
// opened={opened}
// onClick={toggle}
// hiddenFrom="sm"
// size="md"
// color={colors["blue-button"]}
// mr="xs"
// />
// <Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
// <ActionIcon
// onClick={() => {
// router.push("/darmasaba");
// }}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// >
// <Image
// src="/assets/images/darmasaba-icon.png"
// alt="Logo Darmasaba"
// w={20}
// h={20}
// radius="md"
// loading="lazy"
// style={{
// minWidth: '20px',
// height: 'auto',
// }}
// />
// </ActionIcon>
// </Tooltip>
// <Tooltip label="Keluar" position="bottom" withArrow>
// <ActionIcon
// onClick={handleLogout}
// color={colors["blue-button"]}
// radius="xl"
// size="lg"
// variant="gradient"
// gradient={{ from: colors["blue-button"], to: "#228be6" }}
// loading={isLoggingOut}
// disabled={isLoggingOut}
// >
// <IconLogout2 size={22} />
// </ActionIcon>
// </Tooltip>
// </Group>
// </Group>
// </AppShellHeader>
// <AppShellNavbar
// component={ScrollArea}
// style={{
// background: "#ffffff",
// borderRight: `1px solid ${colors["blue-button"]}20`,
// }}
// p={{ base: 'xs', sm: 'sm' }}
// >
// <AppShell.Section p="sm">
// {currentNav.map((v, k) => {
// const isParentActive = segments.includes(_.lowerCase(v.name));
// return (
// <NavLink
// key={k}
// defaultOpened={isParentActive}
// c={isParentActive ? colors["blue-button"] : "gray"}
// label={
// <Text fw={isParentActive ? 600 : 400} fz="sm">
// {v.name}
// </Text>
// }
// style={{
// borderRadius: rem(10),
// marginBottom: rem(4),
// transition: "background 150ms ease",
// }}
// styles={{
// root: {
// '&:hover': {
// backgroundColor: 'rgba(25, 113, 194, 0.05)',
// },
// },
// }}
// variant="light"
// active={isParentActive}
// >
// {v.children.map((child, key) => {
// const isChildActive = segments.includes(
// _.lowerCase(child.name)
// );
// return (
// <NavLink
// key={key}
// href={child.path}
// c={isChildActive ? colors["blue-button"] : "gray"}
// label={
// <Text fw={isChildActive ? 600 : 400} fz="sm">
// {child.name}
// </Text>
// }
// styles={{
// root: {
// borderRadius: rem(8),
// marginBottom: rem(2),
// transition: 'background 150ms ease',
// padding: '6px 12px',
// '&:hover': {
// backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
// },
// ...(isChildActive && {
// backgroundColor: 'rgba(25, 113, 194, 0.1)',
// }),
// },
// }}
// active={isChildActive}
// component={Link}
// />
// );
// })}
// </NavLink>
// );
// })}
// </AppShell.Section>
// <AppShell.Section py="md">
// <Group justify="end" pr="sm">
// <Tooltip
// label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"}
// position="top"
// withArrow
// >
// <ActionIcon
// variant="light"
// radius="xl"
// size="lg"
// onClick={toggleDesktop}
// color={colors["blue-button"]}
// >
// <IconChevronLeft />
// </ActionIcon>
// </Tooltip>
// </Group>
// </AppShell.Section>
// </AppShellNavbar>
// <AppShellMain
// style={{
// background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
// minHeight: "100vh",
// }}
// >
// {children}
// </AppShellMain>
// </AppShell>
// );
// }
// app/admin/layout.tsx
'use client' 'use client'
import colors from "@/con/colors"; import colors from "@/con/colors";
@@ -426,6 +30,7 @@ import _ from "lodash";
import Link from "next/link"; import Link from "next/link";
import { useRouter, useSelectedLayoutSegments } from "next/navigation"; import { useRouter, useSelectedLayoutSegments } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
// import { useSnapshot } from "valtio";
import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar"; import { getNavbar } from "./(dashboard)/user&role/_com/dynamicNavbar";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
@@ -435,53 +40,42 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true); const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(true);
const router = useRouter(); const router = useRouter();
const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s)); const segments = useSelectedLayoutSegments().map((s) => _.lowerCase(s));
// const { user } = useSnapshot(authStore);
// console.log("Current user in store:", user);
// ✅ FIX: Selalu fetch user data setiap kali komponen mount
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
try { try {
const res = await fetch('/api/auth/me', { const res = await fetch('/api/me');
credentials: 'include' // ✅ ADD credentials
});
const data = await res.json(); const data = await res.json();
if (data.user) { if (data.user) {
// Check if user is NOT active → redirect to waiting room // Check if user is active
if (!data.user.isActive) { if (!data.user.isActive) {
authStore.setUser(null); authStore.setUser(null);
router.replace('/waiting-room'); router.replace('/waiting-room');
return; return;
} }
// ✅ Fetch menuIds // ✅ PENTING: Selalu fetch menuIds terbaru setiap login
const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`, { const menuRes = await fetch(`/api/admin/user-menu-access?userId=${data.user.id}`);
credentials: 'include' // ✅ ADD credentials
});
const menuData = await menuRes.json(); const menuData = await menuRes.json();
const menuIds = menuData.success && Array.isArray(menuData.menuIds) const menuIds = menuData.success && Array.isArray(menuData.menuIds)
? [...menuData.menuIds] ? [...menuData.menuIds]
: null; : null;
// ✅ Set user dengan menuIds yang fresh // ✅ Set user dengan menuIds yang fresh dari database
authStore.setUser({ authStore.setUser({
id: data.user.id, id: data.user.id,
name: data.user.name, name: data.user.name,
roleId: Number(data.user.roleId), roleId: Number(data.user.roleId),
menuIds, menuIds, // menuIds terbaru
isActive: data.user.isActive isActive: data.user.isActive
}); });
// ✅ IMPROVED: Redirect ONLY if di root /admin
const currentPath = window.location.pathname;
if (currentPath === '/admin') {
const expectedPath = getRedirectPath(Number(data.user.roleId));
console.log('🔄 Redirecting from /admin to:', expectedPath);
router.replace(expectedPath);
}
// ✅ Jangan redirect jika user sudah di path yang valid
} else { } else {
authStore.setUser(null); authStore.setUser(null);
router.replace('/login'); router.replace('/login');
@@ -496,22 +90,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}; };
fetchUser(); fetchUser();
}, [router]); // ✅ Only depend on router }, [router]); // ✅ Hapus dependency pada authStore.user
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0: // DEVELOPER
case 1: // SUPERADMIN
case 2: // ADMIN_DESA
return '/admin/landing-page/profil/program-inovasi';
case 3: // ADMIN_KESEHATAN
return '/admin/kesehatan/posyandu';
case 4: // ADMIN_PENDIDIKAN
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
if (loading) { if (loading) {
return ( return (
@@ -525,6 +104,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
); );
} }
// ✅ Ambil menu berdasarkan roleId dan menuIds
const currentNav = authStore.user const currentNav = authStore.user
? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds }) ? getNavbar({ roleId: authStore.user.roleId, menuIds: authStore.user.menuIds })
: []; : [];
@@ -532,26 +112,30 @@ export default function Layout({ children }: { children: React.ReactNode }) {
const handleLogout = async () => { const handleLogout = async () => {
try { try {
setIsLoggingOut(true); setIsLoggingOut(true);
const response = await fetch('/api/auth/logout', { // ✅ Panggil API logout untuk clear session di server
method: 'POST', const response = await fetch('/api/logout', { method: 'POST' });
credentials: 'include' // ✅ ADD credentials
});
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
// Clear user data dari store
authStore.setUser(null); authStore.setUser(null);
// Clear localStorage
localStorage.removeItem('auth_nomor'); localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_kodeId'); localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_username');
// Force reload untuk reset semua state
window.location.href = '/login'; window.location.href = '/login';
} else { } else {
console.error('Logout failed:', result.message); console.error('Logout failed:', result.message);
// Tetap redirect meskipun gagal
authStore.setUser(null); authStore.setUser(null);
window.location.href = '/login'; window.location.href = '/login';
} }
} catch (error) { } catch (error) {
console.error('Error during logout:', error); console.error('Error during logout:', error);
// Tetap clear store dan redirect jika error
authStore.setUser(null); authStore.setUser(null);
window.location.href = '/login'; window.location.href = '/login';
} finally { } finally {
@@ -573,7 +157,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
}} }}
padding="md" padding="md"
> >
{/* ... rest of your JSX (Header, Navbar, Main) sama seperti sebelumnya ... */}
<AppShellHeader <AppShellHeader
style={{ style={{
background: "linear-gradient(90deg, #ffffff, #f9fbff)", background: "linear-gradient(90deg, #ffffff, #f9fbff)",
@@ -592,9 +175,16 @@ export default function Layout({ children }: { children: React.ReactNode }) {
h={{ base: 32, sm: 40 }} h={{ base: 32, sm: 40 }}
radius="md" radius="md"
loading="lazy" loading="lazy"
style={{ minWidth: '32px', height: 'auto' }} style={{
minWidth: '32px',
height: 'auto',
}}
/> />
<Text fw={700} c={colors["blue-button"]} fz={{ base: 'md', sm: 'xl' }}> <Text
fw={700}
c={colors["blue-button"]}
fz={{ base: 'md', sm: 'xl' }}
>
Admin Darmasaba Admin Darmasaba
</Text> </Text>
</Flex> </Flex>
@@ -602,22 +192,63 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<Group gap="xs"> <Group gap="xs">
{!desktopOpened && ( {!desktopOpened && (
<Tooltip label="Buka Navigasi" position="bottom" withArrow> <Tooltip label="Buka Navigasi" position="bottom" withArrow>
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}> <ActionIcon
variant="light"
radius="xl"
size="lg"
onClick={toggleDesktop}
color={colors["blue-button"]}
>
<IconChevronRight /> <IconChevronRight />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)} )}
<Burger opened={opened} onClick={toggle} hiddenFrom="sm" size="md" color={colors["blue-button"]} mr="xs" /> <Burger
opened={opened}
onClick={toggle}
hiddenFrom="sm"
size="md"
color={colors["blue-button"]}
mr="xs"
/>
<Tooltip label="Kembali ke Website Desa" position="bottom" withArrow> <Tooltip label="Kembali ke Website Desa" position="bottom" withArrow>
<ActionIcon onClick={() => router.push("/darmasaba")} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }}> <ActionIcon
<Image src="/assets/images/darmasaba-icon.png" alt="Logo Darmasaba" w={20} h={20} radius="md" loading="lazy" style={{ minWidth: '20px', height: 'auto' }} /> onClick={() => {
router.push("/darmasaba");
}}
color={colors["blue-button"]}
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
>
<Image
src="/assets/images/darmasaba-icon.png"
alt="Logo Darmasaba"
w={20}
h={20}
radius="md"
loading="lazy"
style={{
minWidth: '20px',
height: 'auto',
}}
/>
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
<Tooltip label="Keluar" position="bottom" withArrow> <Tooltip label="Keluar" position="bottom" withArrow>
<ActionIcon onClick={handleLogout} color={colors["blue-button"]} radius="xl" size="lg" variant="gradient" gradient={{ from: colors["blue-button"], to: "#228be6" }} loading={isLoggingOut} disabled={isLoggingOut}> <ActionIcon
onClick={handleLogout}
color={colors["blue-button"]}
radius="xl"
size="lg"
variant="gradient"
gradient={{ from: colors["blue-button"], to: "#228be6" }}
loading={isLoggingOut}
disabled={isLoggingOut}
>
<IconLogout2 size={22} /> <IconLogout2 size={22} />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -625,17 +256,75 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</Group> </Group>
</AppShellHeader> </AppShellHeader>
<AppShellNavbar component={ScrollArea} style={{ background: "#ffffff", borderRight: `1px solid ${colors["blue-button"]}20` }} p={{ base: 'xs', sm: 'sm' }}> <AppShellNavbar
{/* ... Navbar content sama seperti sebelumnya ... */} component={ScrollArea}
style={{
background: "#ffffff",
borderRight: `1px solid ${colors["blue-button"]}20`,
}}
p={{ base: 'xs', sm: 'sm' }}
>
<AppShell.Section p="sm"> <AppShell.Section p="sm">
{currentNav.map((v, k) => { {currentNav.map((v, k) => {
const isParentActive = segments.includes(_.lowerCase(v.name)); const isParentActive = segments.includes(_.lowerCase(v.name));
return ( return (
<NavLink key={k} defaultOpened={isParentActive} c={isParentActive ? colors["blue-button"] : "gray"} label={<Text fw={isParentActive ? 600 : 400} fz="sm">{v.name}</Text>} style={{ borderRadius: rem(10), marginBottom: rem(4), transition: "background 150ms ease" }} styles={{ root: { '&:hover': { backgroundColor: 'rgba(25, 113, 194, 0.05)' } } }} variant="light" active={isParentActive}> <NavLink
key={k}
defaultOpened={isParentActive}
c={isParentActive ? colors["blue-button"] : "gray"}
label={
<Text fw={isParentActive ? 600 : 400} fz="sm">
{v.name}
</Text>
}
style={{
borderRadius: rem(10),
marginBottom: rem(4),
transition: "background 150ms ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'rgba(25, 113, 194, 0.05)',
},
},
}}
variant="light"
active={isParentActive}
>
{v.children.map((child, key) => { {v.children.map((child, key) => {
const isChildActive = segments.includes(_.lowerCase(child.name)); const isChildActive = segments.includes(
_.lowerCase(child.name)
);
return ( return (
<NavLink key={key} href={child.path} c={isChildActive ? colors["blue-button"] : "gray"} label={<Text fw={isChildActive ? 600 : 400} fz="sm">{child.name}</Text>} styles={{ root: { borderRadius: rem(8), marginBottom: rem(2), transition: 'background 150ms ease', padding: '6px 12px', '&:hover': { backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)' }, ...(isChildActive && { backgroundColor: 'rgba(25, 113, 194, 0.1)' }) } }} active={isChildActive} component={Link} /> <NavLink
key={key}
href={child.path}
c={isChildActive ? colors["blue-button"] : "gray"}
label={
<Text fw={isChildActive ? 600 : 400} fz="sm">
{child.name}
</Text>
}
styles={{
root: {
borderRadius: rem(8),
marginBottom: rem(2),
transition: 'background 150ms ease',
padding: '6px 12px',
'&:hover': {
backgroundColor: isChildActive ? 'rgba(25, 113, 194, 0.15)' : 'rgba(25, 113, 194, 0.05)',
},
...(isChildActive && {
backgroundColor: 'rgba(25, 113, 194, 0.1)',
}),
},
}}
active={isChildActive}
component={Link}
/>
); );
})} })}
</NavLink> </NavLink>
@@ -645,8 +334,18 @@ export default function Layout({ children }: { children: React.ReactNode }) {
<AppShell.Section py="md"> <AppShell.Section py="md">
<Group justify="end" pr="sm"> <Group justify="end" pr="sm">
<Tooltip label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"} position="top" withArrow> <Tooltip
<ActionIcon variant="light" radius="xl" size="lg" onClick={toggleDesktop} color={colors["blue-button"]}> label={desktopOpened ? "Tutup Navigasi" : "Buka Navigasi"}
position="top"
withArrow
>
<ActionIcon
variant="light"
radius="xl"
size="lg"
onClick={toggleDesktop}
color={colors["blue-button"]}
>
<IconChevronLeft /> <IconChevronLeft />
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
@@ -654,7 +353,12 @@ export default function Layout({ children }: { children: React.ReactNode }) {
</AppShell.Section> </AppShell.Section>
</AppShellNavbar> </AppShellNavbar>
<AppShellMain style={{ background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)", minHeight: "100vh" }}> <AppShellMain
style={{
background: "linear-gradient(180deg, #fdfdfd, #f6f9fc)",
minHeight: "100vh",
}}
>
{children} {children}
</AppShellMain> </AppShellMain>
</AppShell> </AppShell>

View File

@@ -1,16 +1,18 @@
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
type FasilitasKesehatanInput = {
name: string;
informasiUmum: { fasilitas: string; alamat: string; jamOperasional: string };
layananUnggulan: { content: string };
dokterdanTenagaMedis: { name: string; specialist: string; jadwal: string };
fasilitasPendukung: { content: string };
prosedurPendaftaran: { content: string };
tarifDanLayanan: { layanan: string; tarif: string };
};
const fasilitasKesehatanCreate = async (context: Context) => { const fasilitasKesehatanCreate = async (context: Context) => {
const body = (await context.body) as { const body = await context.body as FasilitasKesehatanInput;
name: string;
informasiUmum: { fasilitas: string; alamat: string; jamOperasional: string };
layananUnggulan: { content: string };
dokterdanTenagaMedis: string[]; // ← ARRAY OF ID
fasilitasPendukung: { content: string };
prosedurPendaftaran: { content: string };
tarifDanLayanan: string[]; // ← ARRAY OF ID
};
const { const {
name, name,
@@ -22,30 +24,25 @@ const fasilitasKesehatanCreate = async (context: Context) => {
tarifDanLayanan, tarifDanLayanan,
} = body; } = body;
// CREATE SINGLE DATA // Buat masing-masing relasi terlebih dahulu
const [createdInformasi, createdUnggulan, createdPendukung, createdProsedur] = const [createdInformasiUmum, createdLayananUnggulan, createdDokter, createdPendukung, createdProsedur, createdTarif] = await Promise.all([
await Promise.all([ prisma.informasiUmum.create({ data: informasiUmum }),
prisma.informasiUmum.create({ data: informasiUmum }), prisma.layananUnggulan.create({ data: layananUnggulan }),
prisma.layananUnggulan.create({ data: layananUnggulan }), prisma.dokterdanTenagaMedis.create({ data: dokterdanTenagaMedis }),
prisma.fasilitasPendukung.create({ data: fasilitasPendukung }), prisma.fasilitasPendukung.create({ data: fasilitasPendukung }),
prisma.prosedurPendaftaran.create({ data: prosedurPendaftaran }), prisma.prosedurPendaftaran.create({ data: prosedurPendaftaran }),
]); prisma.tarifDanLayanan.create({ data: tarifDanLayanan }),
]);
// ✅ CUKUP CONNECT KE ID YANG SUDAH ADA
const fasilitas = await prisma.fasilitasKesehatan.create({ const fasilitas = await prisma.fasilitasKesehatan.create({
data: { data: {
name, name,
informasiUmumId: createdInformasi.id, informasiUmumId: createdInformasiUmum.id,
layananUnggulanId: createdUnggulan.id, layananUnggulanId: createdLayananUnggulan.id,
dokterdanTenagaMedisId: createdDokter.id,
fasilitasPendukungId: createdPendukung.id, fasilitasPendukungId: createdPendukung.id,
prosedurPendaftaranId: createdProsedur.id, prosedurPendaftaranId: createdProsedur.id,
tarifDanLayananId: createdTarif.id,
dokterdantenagamedis: {
connect: dokterdanTenagaMedis.map(id => ({ id })), // ← langsung dari input
},
tarifdanlayanan: {
connect: tarifDanLayanan.map(id => ({ id })), // ← langsung dari input
},
}, },
include: { include: {
informasiumum: true, informasiumum: true,

View File

@@ -2,14 +2,42 @@ import prisma from "@/lib/prisma";
import { Context } from "elysia"; import { Context } from "elysia";
const fasilitasKesehatanDelete = async (context: Context) => { const fasilitasKesehatanDelete = async (context: Context) => {
const id = context.params?.id as string; const id = context.params?.id as string;
const data = await prisma.fasilitasKesehatan.findUnique({ where: { id } }); if (!id) {
if (!data) return { status: 404, message: "Data tidak ditemukan" }; return {
status: 400,
message: "ID tidak ditemukan",
}
}
await prisma.fasilitasKesehatan.delete({ where: { id } }); const fasilitasKesehatan = await prisma.fasilitasKesehatan.findUnique({
where: { id },
include: {
informasiumum: true,
layananunggulan: true,
dokterdantenagamedis: true,
fasilitaspendukung: true,
prosedurpendaftaran: true,
tarifdanlayanan: true,
}
})
return { success: true, message: "Berhasil dihapus" }; if (!fasilitasKesehatan) {
}; return {
status: 404,
message: "Fasilitas kesehatan tidak ditemukan",
}
}
export default fasilitasKesehatanDelete; await prisma.fasilitasKesehatan.delete({
where: { id },
})
return {
status: 200,
success: true,
message: "Fasilitas kesehatan berhasil dihapus",
}
}
export default fasilitasKesehatanDelete

View File

@@ -5,11 +5,6 @@ type FormCreate = {
name: string; name: string;
specialist: string; specialist: string;
jadwal: string; jadwal: string;
jadwalLibur: string;
jamBukaOperasional: string;
jamTutupOperasional: string;
jamBukaLibur: string;
jamTutupLibur: string;
}; };
export default async function dokterTenagaMedisCreate(context: Context) { export default async function dokterTenagaMedisCreate(context: Context) {
@@ -20,21 +15,11 @@ export default async function dokterTenagaMedisCreate(context: Context) {
name: body.name, name: body.name,
specialist: body.specialist, specialist: body.specialist,
jadwal: body.jadwal, jadwal: body.jadwal,
jadwalLibur: body.jadwalLibur,
jamBukaOperasional: body.jamBukaOperasional,
jamTutupOperasional: body.jamTutupOperasional,
jamBukaLibur: body.jamBukaLibur,
jamTutupLibur: body.jamTutupLibur,
}, },
select: { select: {
name: true, name: true,
specialist: true, specialist: true,
jadwal: true, jadwal: true,
jadwalLibur: true,
jamBukaOperasional: true,
jamTutupOperasional: true,
jamBukaLibur: true,
jamTutupLibur: true,
} }
}); });

View File

@@ -19,7 +19,6 @@ async function dokterTenagaMedisFindMany(context: Context) {
{ name: { contains: search, mode: 'insensitive' } }, { name: { contains: search, mode: 'insensitive' } },
{ specialist: { contains: search, mode: 'insensitive' } }, { specialist: { contains: search, mode: 'insensitive' } },
{ jadwal: { contains: search, mode: 'insensitive' } }, { jadwal: { contains: search, mode: 'insensitive' } },
{ jadwalLibur: { contains: search, mode: 'insensitive' } },
]; ];
} }

View File

@@ -19,11 +19,6 @@ const DokterTenagaMedis = new Elysia({
name: t.String(), name: t.String(),
specialist: t.String(), specialist: t.String(),
jadwal: t.String(), jadwal: t.String(),
jadwalLibur: t.String(),
jamBukaOperasional: t.String(),
jamTutupOperasional: t.String(),
jamBukaLibur: t.String(),
jamTutupLibur: t.String(),
}), }),
}) })
.put("/:id", dokterTenagaMedisUpdate, { .put("/:id", dokterTenagaMedisUpdate, {
@@ -31,11 +26,6 @@ const DokterTenagaMedis = new Elysia({
name: t.String(), name: t.String(),
specialist: t.String(), specialist: t.String(),
jadwal: t.String(), jadwal: t.String(),
jadwalLibur: t.String(),
jamBukaOperasional: t.String(),
jamTutupOperasional: t.String(),
jamBukaLibur: t.String(),
jamTutupLibur: t.String(),
}), }),
}) })
.delete("/del/:id", dokterTenagaMedisDelete) .delete("/del/:id", dokterTenagaMedisDelete)

View File

@@ -5,11 +5,6 @@ type FormUpdate = {
name: string; name: string;
specialist: string; specialist: string;
jadwal: string; jadwal: string;
jadwalLibur: string;
jamBukaOperasional: string;
jamTutupOperasional: string;
jamBukaLibur: string;
jamTutupLibur: string;
} }
export default async function dokterTenagaMedisUpdate(context: Context) { export default async function dokterTenagaMedisUpdate(context: Context) {
@@ -23,12 +18,6 @@ export default async function dokterTenagaMedisUpdate(context: Context) {
name: body.name, name: body.name,
specialist: body.specialist, specialist: body.specialist,
jadwal: body.jadwal, jadwal: body.jadwal,
jadwalLibur: body.jadwalLibur,
jamBukaOperasional: body.jamBukaOperasional,
jamTutupOperasional: body.jamTutupOperasional,
jamBukaLibur: body.jamBukaLibur,
jamTutupLibur: body.jamTutupLibur,
}, },
}); });
return { return {

View File

@@ -1,6 +1,6 @@
import { Elysia, t } from "elysia"; import { Elysia, t } from "elysia";
import fasilitasKesehatanCreate from "./create"; import fasilitasKesehatanCreate from "./create";
import fasilitasKesehatanFindMany from "./findMany"; import findManyFasilitasKesehatan from "./findMany";
import findUniqueFasilitasKesehatan from "./findUnique"; import findUniqueFasilitasKesehatan from "./findUnique";
import fasilitasKesehatanUpdate from "./updt"; import fasilitasKesehatanUpdate from "./updt";
import fasilitasKesehatanDelete from "./del"; import fasilitasKesehatanDelete from "./del";
@@ -9,61 +9,42 @@ const FasilitasKesehatan = new Elysia({
prefix: "fasilitas-kesehatan", prefix: "fasilitas-kesehatan",
tags: ["Kesehatan/Fasilitas Kesehatan"], tags: ["Kesehatan/Fasilitas Kesehatan"],
}) })
// ==========================
// CREATE
// ==========================
.post("/create", fasilitasKesehatanCreate, { .post("/create", fasilitasKesehatanCreate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
informasiUmum: t.Object({ informasiUmum: t.Object({
fasilitas: t.String(), fasilitas: t.String(),
alamat: t.String(), alamat: t.String(),
jamOperasional: t.String(), jamOperasional: t.String(),
}), }),
layananUnggulan: t.Object({ layananUnggulan: t.Object({
content: t.String(), content: t.String(),
}), }),
dokterdanTenagaMedis: t.Object({
dokterdanTenagaMedis: t.Array(t.String()), // FIX karena create pakai array of string name: t.String(),
specialist: t.String(),
jadwal: t.String(),
}),
fasilitasPendukung: t.Object({ fasilitasPendukung: t.Object({
content: t.String(), content: t.String(),
}), }),
prosedurPendaftaran: t.Object({ prosedurPendaftaran: t.Object({
content: t.String(), content: t.String(),
}), }),
tarifDanLayanan: t.Object({
tarifDanLayanan: t.Array(t.String()), // FIX karena create pakai array of string layanan: t.String(),
tarif: t.String(),
}),
}), }),
}) })
.get("/find-many", findManyFasilitasKesehatan)
// ==========================
// FIND MANY
// ==========================
.get("/find-many", fasilitasKesehatanFindMany)
// ==========================
// DELETE
// ==========================
.delete("/del/:id", fasilitasKesehatanDelete) .delete("/del/:id", fasilitasKesehatanDelete)
// ==========================
// FIND UNIQUE
// ==========================
.get("/:id", async (context) => { .get("/:id", async (context) => {
const response = await findUniqueFasilitasKesehatan( const response = await findUniqueFasilitasKesehatan(
new Request(context.request) new Request(context.request)
); );
return response; return response;
}) })
// ==========================
// UPDATE
// ==========================
.put( .put(
"/:id", "/:id",
async (context) => { async (context) => {
@@ -73,30 +54,29 @@ const FasilitasKesehatan = new Elysia({
{ {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
informasiUmum: t.Object({ informasiUmum: t.Object({
fasilitas: t.String(), fasilitas: t.String(),
alamat: t.String(), alamat: t.String(),
jamOperasional: t.String(), jamOperasional: t.String(),
}), }),
layananUnggulan: t.Object({ layananUnggulan: t.Object({
content: t.String(), content: t.String(),
}), }),
dokterdanTenagaMedis: t.Object({
// FIX → harus array of string (ID dokter) name: t.String(),
dokterdanTenagaMedis: t.Array(t.String()), specialist: t.String(),
jadwal: t.String(),
}),
fasilitasPendukung: t.Object({ fasilitasPendukung: t.Object({
content: t.String(), content: t.String(),
}), }),
prosedurPendaftaran: t.Object({ prosedurPendaftaran: t.Object({
content: t.String(), content: t.String(),
}), }),
tarifDanLayanan: t.Object({
// FIX → harus array of string (ID tarif) layanan: t.String(),
tarifDanLayanan: t.Array(t.String()), tarif: t.String(),
}),
}), }),
} }
); );

View File

@@ -1,28 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormCreate = {
layanan: string;
tarif: string;
};
export default async function tarifLayananCreate(context: Context) {
const body = context.body as FormCreate;
const created = await prisma.tarifDanLayanan.create({
data: {
layanan: body.layanan,
tarif: body.tarif,
},
select: {
layanan: true,
tarif: true,
}
});
return {
success: true,
message: "Sukses menambahkan dokter tenaga medis",
data: created,
};
}

View File

@@ -1,37 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
export default async function tarifLayananDelete(context: Context) {
const id = context.params?.id;
if (!id) {
return {
success: false,
message: "ID tidak ditemukan",
};
}
const existing = await prisma.tarifDanLayanan.findUnique({
where: {
id: id,
},
});
if (!existing) {
return {
success: false,
message: "Data tidak ditemukan",
};
}
const deleted = await prisma.tarifDanLayanan.delete({
where: { id },
});
return {
success: true,
message: "Data berhasil dihapus",
data: deleted,
};
}

View File

@@ -1,54 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// /api/berita/findManyPaginated.ts
import prisma from "@/lib/prisma";
import { Context } from "elysia";
async function tarifLayananFindMany(context: Context) {
// Ambil parameter dari query
const page = Number(context.query.page) || 1;
const limit = Number(context.query.limit) || 10;
const search = (context.query.search as string) || '';
const skip = (page - 1) * limit;
// Buat where clause
const where: any = { isActive: true };
// Tambahkan pencarian (jika ada)
if (search) {
where.OR = [
{ layanan: { contains: search, mode: 'insensitive' } },
{ tarif: { contains: search, mode: 'insensitive' } },
];
}
try {
// Ambil data dan total count secara paralel
const [data, total] = await Promise.all([
prisma.tarifDanLayanan.findMany({
where,
skip,
take: limit,
orderBy: { layanan: 'asc' },
}),
prisma.tarifDanLayanan.count({ where }),
]);
return {
success: true,
message: "Berhasil ambil data tarif layanan dengan pagination",
data,
page,
limit,
total,
totalPages: Math.ceil(total / limit),
};
} catch (e) {
console.error("Error di findMany paginated:", e);
return {
success: false,
message: "Gagal mengambil data tarif layanan",
};
}
}
export default tarifLayananFindMany;

View File

@@ -1,47 +0,0 @@
import prisma from "@/lib/prisma";
export default async function tarifLayananFindUnique(request: Request) {
const url = new URL(request.url);
const pathSegments = url.pathname.split('/');
const id = pathSegments[pathSegments.length - 1];
if (!id) {
return Response.json({
success: false,
message: 'ID tidak boleh kosong',
}, {status: 400})
}
try {
if (typeof id !== 'string') {
return Response.json({
success: false,
message: "ID tidak valid",
}, { status: 400 });
}
const data = await prisma.tarifDanLayanan.findUnique({
where: { id },
});
if (!data) {
return Response.json({
success: false,
message: "Data tidak ditemukan",
}, { status: 404 });
}
return Response.json({
success: true,
message: "Berhasil mengambil data berdasarkan ID",
data,
}, { status: 200 });
} catch (error) {
console.error("Error fetching data:", error);
return Response.json({
success: false,
message: "Terjadi kesalahan saat mengambil data",
}, { status: 500 });
}
}

View File

@@ -1,30 +0,0 @@
import Elysia, { t } from "elysia";
import tarifLayananCreate from "./create";
import tarifLayananFindMany from "./findMany";
import tarifLayananFindUnique from "./findUnique";
import tarifLayananDelete from "./del";
import tarifLayananUpdate from "./updt";
const TarifLayanan = new Elysia({
prefix: "/tarifdanlayanan",
tags: ["Data Kesehatan/Fasilitas Kesehatan/Tarif Layanan"]
})
.get("/:id", async (context) => {
const response = await tarifLayananFindUnique(new Request(context.request));
return response;
})
.get("/findMany", tarifLayananFindMany)
.post("/create", tarifLayananCreate, {
body: t.Object({
tarif: t.String(),
layanan: t.String()
}),
})
.put("/:id", tarifLayananUpdate, {
body: t.Object({
tarif: t.String(),
layanan: t.String(),
}),
})
.delete("/del/:id", tarifLayananDelete)
export default TarifLayanan

View File

@@ -1,32 +0,0 @@
import prisma from "@/lib/prisma";
import { Context } from "elysia";
type FormUpdate = {
layanan: string;
tarif: string;
};
export default async function tarifLayananUpdate(context: Context) {
const body = (await context.body) as FormUpdate;
const id = context.params.id as string;
try {
const result = await prisma.tarifDanLayanan.update({
where: { id },
data: {
layanan: body.layanan,
tarif: body.tarif,
},
});
return {
success: true,
message: "Berhasil mengupdate data tarif layanan",
data: result,
};
} catch (error) {
console.error("Error updating data tarif layanan:", error);
throw new Error(
"Gagal mengupdate data tarif layanan: " + (error as Error).message
);
}
}

View File

@@ -5,26 +5,32 @@ type FasilitasKesehatanInput = {
name: string; name: string;
informasiUmum: { fasilitas: string; alamat: string; jamOperasional: string }; informasiUmum: { fasilitas: string; alamat: string; jamOperasional: string };
layananUnggulan: { content: string }; layananUnggulan: { content: string };
dokterdanTenagaMedis: string[]; // ← ID saja dokterdanTenagaMedis: { name: string; specialist: string; jadwal: string };
fasilitasPendukung: { content: string }; fasilitasPendukung: { content: string };
prosedurPendaftaran: { content: string }; prosedurPendaftaran: { content: string };
tarifDanLayanan: string[]; // ← ID saja tarifDanLayanan: { layanan: string; tarif: string };
}; };
const fasilitasKesehatanUpdate = async (context: Context) => { const fasilitasKesehatanUpdate = async (context: Context) => {
const id = context.params?.id as string; const id = context.params?.id as string;
const body = (await context.body) as FasilitasKesehatanInput; const body = await context.body as FasilitasKesehatanInput;
if (!id) {
return new Response(
JSON.stringify({ success: false, message: "ID is required" }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
const existing = await prisma.fasilitasKesehatan.findUnique({ const existing = await prisma.fasilitasKesehatan.findUnique({
where: { id }, where: { id },
include: {
dokterdantenagamedis: true,
tarifdanlayanan: true,
},
}); });
if (!existing) { if (!existing) {
return { success: false, message: "Data tidak ditemukan" }; return new Response(
JSON.stringify({ success: false, message: "Data not found" }),
{ status: 404, headers: { "Content-Type": "application/json" } }
);
} }
const { const {
@@ -37,46 +43,38 @@ const fasilitasKesehatanUpdate = async (context: Context) => {
tarifDanLayanan, tarifDanLayanan,
} = body; } = body;
// update relasi 1-1 // Update data masing-masing relasi
await Promise.all([ await Promise.all([
prisma.informasiUmum.update({ prisma.informasiUmum.update({
where: { id: existing.informasiUmumId }, where: { id: existing.informasiUmumId },
data: informasiUmum, data: informasiUmum,
}), }),
prisma.layananUnggulan.update({ prisma.layananUnggulan.update({
where: { id: existing.layananUnggulanId }, where: { id: existing.layananUnggulanId },
data: layananUnggulan, data: layananUnggulan,
}), }),
prisma.dokterdanTenagaMedis.update({
where: { id: existing.dokterdanTenagaMedisId },
data: dokterdanTenagaMedis,
}),
prisma.fasilitasPendukung.update({ prisma.fasilitasPendukung.update({
where: { id: existing.fasilitasPendukungId }, where: { id: existing.fasilitasPendukungId },
data: fasilitasPendukung, data: fasilitasPendukung,
}), }),
prisma.prosedurPendaftaran.update({ prisma.prosedurPendaftaran.update({
where: { id: existing.prosedurPendaftaranId }, where: { id: existing.prosedurPendaftaranId },
data: prosedurPendaftaran, data: prosedurPendaftaran,
}), }),
prisma.tarifDanLayanan.update({
where: { id: existing.tarifDanLayananId },
data: tarifDanLayanan,
}),
]); ]);
// update m2m // Update main record
const updated = await prisma.fasilitasKesehatan.update({ const updated = await prisma.fasilitasKesehatan.update({
where: { id }, where: { id },
data: { name },
data: {
name,
// reset dokter lama → ganti baru
dokterdantenagamedis: {
set: dokterdanTenagaMedis.map((id) => ({ id })),
},
tarifdanlayanan: {
set: tarifDanLayanan.map((id) => ({ id })),
},
},
include: { include: {
informasiumum: true, informasiumum: true,
layananunggulan: true, layananunggulan: true,
@@ -89,7 +87,7 @@ const fasilitasKesehatanUpdate = async (context: Context) => {
return { return {
success: true, success: true,
message: "Fasilitas diupdate", message: "Fasilitas berhasil diupdate",
data: updated, data: updated,
}; };
}; };

View File

@@ -20,7 +20,6 @@ import Kelahiran from "./data_kesehatan_warga/persentase_kelahiran_kematian/kela
import Kematian from "./data_kesehatan_warga/persentase_kelahiran_kematian/kematian"; import Kematian from "./data_kesehatan_warga/persentase_kelahiran_kematian/kematian";
import DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter-tenaga-medis"; import DokterTenagaMedis from "./data_kesehatan_warga/fasilitas_kesehatan/dokter-tenaga-medis";
import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran"; import PendaftaranJadwalKegiatan from "./data_kesehatan_warga/jadwal_kegiatan/pendaftaran";
import TarifLayanan from "./data_kesehatan_warga/fasilitas_kesehatan/tarif-layanan";
const Kesehatan = new Elysia({ const Kesehatan = new Elysia({
@@ -47,6 +46,5 @@ const Kesehatan = new Elysia({
.use(Kelahiran) .use(Kelahiran)
.use(Kematian) .use(Kematian)
.use(DokterTenagaMedis) .use(DokterTenagaMedis)
.use(TarifLayanan)
.use(PendaftaranJadwalKegiatan) .use(PendaftaranJadwalKegiatan)
export default Kesehatan; export default Kesehatan;

View File

@@ -5,7 +5,6 @@ type FormCreate = {
name: string; name: string;
imageId: string; imageId: string;
iconUrl: string; iconUrl: string;
icon: string;
}; };
export default async function mediaSosialCreate(context: Context) { export default async function mediaSosialCreate(context: Context) {
@@ -15,9 +14,8 @@ export default async function mediaSosialCreate(context: Context) {
const result = await prisma.mediaSosial.create({ const result = await prisma.mediaSosial.create({
data: { data: {
name: body.name, name: body.name,
imageId: body.imageId || null, imageId: body.imageId,
iconUrl: body.iconUrl, iconUrl: body.iconUrl,
icon: body.icon || null,
}, },
include: { include: {
image: true, image: true,
@@ -31,6 +29,8 @@ export default async function mediaSosialCreate(context: Context) {
}; };
} catch (error) { } catch (error) {
console.error("Error creating media sosial:", error); console.error("Error creating media sosial:", error);
throw new Error("Gagal membuat media sosial: " + (error as Error).message); throw new Error(
"Gagal membuat media sosial: " + (error as Error).message
);
} }
} }

View File

@@ -20,9 +20,8 @@ const MediaSosial = new Elysia({
.post("/create", MediaSosialCreate, { .post("/create", MediaSosialCreate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
imageId: t.Union([t.String(), t.Null()]), imageId: t.String(),
iconUrl: t.Union([t.String(), t.Null()]), iconUrl: t.String(),
icon: t.Union([t.String(), t.Null()]),
}), }),
}) })
@@ -30,9 +29,8 @@ const MediaSosial = new Elysia({
.put("/:id", MediaSosialUpdate, { .put("/:id", MediaSosialUpdate, {
body: t.Object({ body: t.Object({
name: t.String(), name: t.String(),
imageId: t.Optional(t.Union([t.String(), t.Null()])), imageId: t.Optional(t.String()),
iconUrl: t.Optional(t.Union([t.String(), t.Null()])), iconUrl: t.Optional(t.String()),
icon: t.Optional(t.Union([t.String(), t.Null()])),
}), }),
}) })
// ✅ Delete // ✅ Delete

View File

@@ -6,7 +6,6 @@ type FormUpdateMediaSosial = {
name?: string; name?: string;
imageId?: string; imageId?: string;
iconUrl?: string; iconUrl?: string;
icon?: string;
}; };
export default async function mediaSosialUpdate(context: Context) { export default async function mediaSosialUpdate(context: Context) {
@@ -21,29 +20,13 @@ export default async function mediaSosialUpdate(context: Context) {
}; };
} }
// 🚨 Tambahkan validasi di sini
if (!body.name || body.name.trim().length < 3) {
return {
success: false,
message: "Nama media sosial minimal 3 karakter",
};
}
if (!body.iconUrl || body.iconUrl.trim().length < 3) {
return {
success: false,
message: "Icon URL minimal 3 karakter",
};
}
try { try {
const updated = await prisma.mediaSosial.update({ const updated = await prisma.mediaSosial.update({
where: { id }, where: { id },
data: { data: {
name: body.name, name: body.name,
imageId: body.imageId || null, // pastikan null jika kosong imageId: body.imageId,
iconUrl: body.iconUrl, iconUrl: body.iconUrl,
icon: body.icon || null, // pastikan null jika kosong
}, },
include: { include: {
image: true, image: true,

View File

@@ -557,37 +557,25 @@ export default async function searchFindMany(context: Context) {
], ],
}, },
layananunggulan: { content: { contains: query, mode: "insensitive" } }, layananunggulan: { content: { contains: query, mode: "insensitive" } },
dokterdantenagamedis: {
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ specialist: { contains: query, mode: "insensitive" } },
{ jadwal: { contains: query, mode: "insensitive" } },
],
},
fasilitaspendukung: { fasilitaspendukung: {
content: { contains: query, mode: "insensitive" }, content: { contains: query, mode: "insensitive" },
}, },
prosedurpendaftaran: { prosedurpendaftaran: {
content: { contains: query, mode: "insensitive" }, content: { contains: query, mode: "insensitive" },
}, },
}, tarifdanlayanan: {
skip, OR: [
take: limitNum, { layanan: { contains: query, mode: "insensitive" } },
}); { tarif: { contains: query, mode: "insensitive" } },
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 }; ],
} },
if (type === "doktertenagamedis") {
const data = await prisma.dokterdanTenagaMedis.findMany({
where: {
name: { contains: query, mode: "insensitive" },
specialist: { contains: query, mode: "insensitive" },
},
skip,
take: limitNum,
});
return { data, nextPage: data.length < limitNum ? null : pageNum + 1 };
}
if (type === "tarifdanlayanan") {
const data = await prisma.tarifDanLayanan.findMany({
where: {
layanan: { contains: query, mode: "insensitive" },
tarif: { contains: query, mode: "insensitive" },
}, },
skip, skip,
take: limitNum, take: limitNum,
@@ -1579,8 +1567,6 @@ export default async function searchFindMany(context: Context) {
jenisProgramYangDiselenggarakan, jenisProgramYangDiselenggarakan,
dataPerpustakaan, dataPerpustakaan,
dataPendidikan, dataPendidikan,
dokterDanTenagaMedis,
tarifDanLayanan
] = await Promise.all([ ] = await Promise.all([
prisma.pejabatDesa.findMany({ prisma.pejabatDesa.findMany({
where: { name: { contains: query, mode: "insensitive" } }, where: { name: { contains: query, mode: "insensitive" } },
@@ -1908,27 +1894,25 @@ export default async function searchFindMany(context: Context) {
], ],
}, },
layananunggulan: { content: { contains: query, mode: "insensitive" } }, layananunggulan: { content: { contains: query, mode: "insensitive" } },
dokterdantenagamedis: {
OR: [
{ name: { contains: query, mode: "insensitive" } },
{ specialist: { contains: query, mode: "insensitive" } },
{ jadwal: { contains: query, mode: "insensitive" } },
],
},
fasilitaspendukung: { fasilitaspendukung: {
content: { contains: query, mode: "insensitive" }, content: { contains: query, mode: "insensitive" },
}, },
prosedurpendaftaran: { prosedurpendaftaran: {
content: { contains: query, mode: "insensitive" }, content: { contains: query, mode: "insensitive" },
}, },
}, tarifdanlayanan: {
take: limitNum, OR: [
}), { layanan: { contains: query, mode: "insensitive" } },
prisma.dokterdanTenagaMedis.findMany({ { tarif: { contains: query, mode: "insensitive" } },
where: { ],
name: { contains: query, mode: "insensitive" }, },
specialist: { contains: query, mode: "insensitive" },
},
take: limitNum,
}),
prisma.tarifDanLayanan.findMany({
where: {
tarif: { contains: query, mode: "insensitive" },
layanan: { contains: query, mode: "insensitive" },
}, },
take: limitNum, take: limitNum,
}), }),
@@ -2332,7 +2316,7 @@ export default async function searchFindMany(context: Context) {
{ judul: { contains: query, mode: "insensitive" } }, { judul: { contains: query, mode: "insensitive" } },
{ deskripsiSingkat: { contains: query, mode: "insensitive" } }, { deskripsiSingkat: { contains: query, mode: "insensitive" } },
{ deskripsiLengkap: { contains: query, mode: "insensitive" } }, { deskripsiLengkap: { contains: query, mode: "insensitive" } },
{ lokasi: { contains: query, mode: "insensitive" } }, { lokasi: { contains: query, mode: "insensitive" } },
{ {
kategoriKegiatan: { kategoriKegiatan: {
nama: { contains: query, mode: "insensitive" }, nama: { contains: query, mode: "insensitive" },
@@ -2575,8 +2559,6 @@ export default async function searchFindMany(context: Context) {
...penghargaan.map((b) => ({ type: "penghargaan", ...b })), ...penghargaan.map((b) => ({ type: "penghargaan", ...b })),
...posyandu.map((b) => ({ type: "posyandu", ...b })), ...posyandu.map((b) => ({ type: "posyandu", ...b })),
...fasilitasKesehatan.map((b) => ({ type: "fasilitasKesehatan", ...b })), ...fasilitasKesehatan.map((b) => ({ type: "fasilitasKesehatan", ...b })),
...dokterDanTenagaMedis.map((b) => ({ type: "dokterdanTenagaMedis", ...b })),
...tarifDanLayanan.map((b) => ({ type: "tarifDanLayanan", ...b })),
...jadwalKegiatan.map((b) => ({ type: "jadwalKegiatan", ...b })), ...jadwalKegiatan.map((b) => ({ type: "jadwalKegiatan", ...b })),
...artikelKesehatan.map((b) => ({ type: "artikelKesehatan", ...b })), ...artikelKesehatan.map((b) => ({ type: "artikelKesehatan", ...b })),
...puskesmas.map((b) => ({ type: "puskesmas", ...b })), ...puskesmas.map((b) => ({ type: "puskesmas", ...b })),

View File

@@ -23,7 +23,7 @@ export default async function userUpdate(context: Context) {
const currentUser = await prisma.user.findUnique({ const currentUser = await prisma.user.findUnique({
where: { id }, where: { id },
select: { roleId: true, isActive: true }, select: { roleId: true, isActive: true }
}); });
if (!currentUser) { if (!currentUser) {
@@ -31,15 +31,7 @@ export default async function userUpdate(context: Context) {
} }
const isRoleChanged = roleId && currentUser.roleId !== roleId; const isRoleChanged = roleId && currentUser.roleId !== roleId;
const isActiveChanged = const isActiveChanged = isActive !== undefined && currentUser.isActive !== isActive;
isActive !== undefined && currentUser.isActive !== isActive;
// ✅ Jika role berubah, hapus semua akses menu yang ada
if (isRoleChanged) {
await prisma.userMenuAccess.deleteMany({
where: { userId: id }
});
}
// Update user // Update user
const updatedUser = await prisma.user.update({ const updatedUser = await prisma.user.update({
@@ -56,11 +48,10 @@ export default async function userUpdate(context: Context) {
nomor: true, nomor: true,
isActive: true, isActive: true,
roleId: true, roleId: true,
role: { select: { name: true } }, role: { select: { name: true } }
}, }
}); });
// ✅ HAPUS SEMUA SESI USER DI DATABASE // ✅ HAPUS SEMUA SESI USER DI DATABASE
if (isRoleChanged) { if (isRoleChanged) {
await prisma.userSession.deleteMany({ where: { userId: id } }); await prisma.userSession.deleteMany({ where: { userId: id } });
@@ -71,13 +62,11 @@ export default async function userUpdate(context: Context) {
roleChanged: isRoleChanged, roleChanged: isRoleChanged,
isActiveChanged, isActiveChanged,
data: updatedUser, data: updatedUser,
message: isRoleChanged message: isRoleChanged
? `Role ${updatedUser.username} diubah. User akan logout otomatis.` ? `Role ${updatedUser.username} diubah. User akan logout otomatis.`
: isActiveChanged : isActiveChanged
? `${updatedUser.username} ${ ? `${updatedUser.username} ${isActive ? 'diaktifkan' : 'dinonaktifkan'}.`
isActive ? "diaktifkan" : "dinonaktifkan" : "User berhasil diupdate"
}.`
: "User berhasil diupdate",
}; };
} catch (e: any) { } catch (e: any) {
console.error("❌ Error update user:", e); console.error("❌ Error update user:", e);
@@ -86,4 +75,4 @@ export default async function userUpdate(context: Context) {
message: "Gagal mengupdate user: " + (e.message || "Unknown error"), message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
}; };
} }
} }

View File

@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
// app/api/auth/_lib/api_fetch_auth.ts // app/api/_lib/api_fetch_auth.ts
// app/api/auth/_lib/api_fetch_auth.ts // app/api/_lib/api_fetch_auth.ts
export const apiFetchLogin = async ({ nomor }: { nomor: string }) => { export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
if (!nomor || nomor.replace(/\D/g, '').length < 10) { if (!nomor || nomor.replace(/\D/g, '').length < 10) {
@@ -10,7 +10,7 @@ export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
const cleanPhone = nomor.replace(/\D/g, ''); const cleanPhone = nomor.replace(/\D/g, '');
const response = await fetch("/api/auth/login", { const response = await fetch("/api/login", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomor: cleanPhone }), body: JSON.stringify({ nomor: cleanPhone }),
@@ -22,7 +22,7 @@ export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
try { try {
data = await response.json(); data = await response.json();
} catch (e) { } catch (e) {
console.error("Non-JSON response from /api/auth/login:", await response.text()); console.error("Non-JSON response from /api/login:", await response.text());
throw new Error('Respons server tidak valid'); throw new Error('Respons server tidak valid');
} }
@@ -55,7 +55,7 @@ export const apiFetchRegister = async ({
const cleanPhone = nomor.replace(/\D/g, ''); const cleanPhone = nomor.replace(/\D/g, '');
if (cleanPhone.length < 10) throw new Error('Nomor tidak valid'); if (cleanPhone.length < 10) throw new Error('Nomor tidak valid');
const response = await fetch("/api/auth/register", { const response = await fetch("/api/register", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }), body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
@@ -73,7 +73,7 @@ export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
throw new Error('Kode ID tidak valid'); throw new Error('Kode ID tidak valid');
} }
const response = await fetch("/api/auth/otp-data", { const response = await fetch("/api/otp-data", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kodeId }), body: JSON.stringify({ kodeId }),
@@ -90,7 +90,7 @@ export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
// Ganti endpoint ke verify-otp-login // Ganti endpoint ke verify-otp-login
export const apiFetchVerifyOtp = async ({ nomor, otp, kodeId }: { nomor: string; otp: string; kodeId: string }) => { export const apiFetchVerifyOtp = async ({ nomor, otp, kodeId }: { nomor: string; otp: string; kodeId: string }) => {
const response = await fetch('/api/auth/verify-otp-login', { const response = await fetch('/api/verify-otp-login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }), body: JSON.stringify({ nomor, otp, kodeId }),

View File

@@ -30,12 +30,7 @@ export async function POST(req: Request) {
); );
} }
// Verify OTP const otpRecord = await prisma.kodeOtp.findUnique({ where: { id: kodeId } });
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { nomor: true, isActive: true }
});
if (!otpRecord?.isActive || otpRecord.nomor !== cleanNomor) { if (!otpRecord?.isActive || otpRecord.nomor !== cleanNomor) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: "OTP tidak valid" }, { success: false, message: "OTP tidak valid" },
@@ -43,7 +38,6 @@ export async function POST(req: Request) {
); );
} }
// Check duplicate username
if (await prisma.user.findFirst({ where: { username } })) { if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Username sudah digunakan" }, { success: false, message: "Username sudah digunakan" },
@@ -51,20 +45,12 @@ export async function POST(req: Request) {
); );
} }
// Check duplicate nomor
if (await prisma.user.findUnique({ where: { nomor: cleanNomor } })) {
return NextResponse.json(
{ success: false, message: "Nomor sudah terdaftar" },
{ status: 409 }
);
}
// 🔥 Tentukan roleId sebagai STRING // 🔥 Tentukan roleId sebagai STRING
const targetRoleId = "2"; // ✅ Default ADMIN_DESA (roleId "2") const targetRoleId = "1"; // ✅ string, bukan number
// Validasi role exists // Validasi role (gunakan string)
const roleExists = await prisma.role.findUnique({ const roleExists = await prisma.role.findUnique({
where: { id: targetRoleId }, where: { id: targetRoleId }, // ✅ id bertipe string
select: { id: true } select: { id: true }
}); });
@@ -75,17 +61,17 @@ export async function POST(req: Request) {
); );
} }
// ✅ Create user (inactive, waiting approval) // Buat user dengan roleId string
const newUser = await prisma.user.create({ const newUser = await prisma.user.create({
data: { data: {
username, username,
nomor: cleanNomor, nomor,
roleId: targetRoleId, roleId: targetRoleId, // ✅ string
isActive: false, // Waiting for admin approval isActive: false,
}, },
}); });
// Berikan akses menu default based on role // Berikan akses menu
const menuIds = DEFAULT_MENUS_BY_ROLE[targetRoleId] || []; const menuIds = DEFAULT_MENUS_BY_ROLE[targetRoleId] || [];
if (menuIds.length > 0) { if (menuIds.length > 0) {
await prisma.userMenuAccess.createMany({ await prisma.userMenuAccess.createMany({
@@ -93,17 +79,14 @@ export async function POST(req: Request) {
userId: newUser.id, userId: newUser.id,
menuId, menuId,
})), })),
skipDuplicates: true, // ✅ Avoid duplicate errors
}); });
} }
// ✅ Mark OTP as used
await prisma.kodeOtp.update({ await prisma.kodeOtp.update({
where: { id: kodeId }, where: { id: kodeId },
data: { isActive: false }, data: { isActive: false },
}); });
// ✅ Create session token
const token = await sessionCreate({ const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!, sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!, jwtSecret: process.env.BASE_TOKEN_KEY!,
@@ -112,35 +95,25 @@ export async function POST(req: Request) {
id: newUser.id, id: newUser.id,
nomor: newUser.nomor, nomor: newUser.nomor,
username: newUser.username, username: newUser.username,
roleId: newUser.roleId, roleId: newUser.roleId, // string
isActive: false, // User belum aktif isActive: false,
}, },
invalidatePrevious: false, invalidatePrevious: false,
}); });
// ✅ PENTING: Return JSON response (bukan redirect) const response = NextResponse.redirect(new URL('/waiting-room', req.url));
const response = NextResponse.json({ response.cookies.set(process.env.BASE_SESSION_KEY!, token, {
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
userId: newUser.id,
});
// ✅ Set session cookie
const cookieName = process.env.BASE_SESSION_KEY || 'session';
response.cookies.set(cookieName, token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", secure: process.env.NODE_ENV === "production",
sameSite: 'lax',
path: "/", path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days maxAge: 30 * 24 * 60 * 60,
}); });
return response; return response;
} catch (error) { } catch (error) {
console.error("❌ Finalize Registration Error:", error); console.error("❌ Finalize Registration Error:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Registrasi gagal. Silakan coba lagi." }, { success: false, message: "Registrasi gagal" },
{ status: 500 } { status: 500 }
); );
} finally { } finally {

View File

@@ -12,7 +12,6 @@ export async function GET() {
{ status: 401 } { status: 401 }
); );
} }
const [dbUser, menuAccess] = await Promise.all([ const [dbUser, menuAccess] = await Promise.all([
prisma.user.findUnique({ prisma.user.findUnique({
@@ -51,7 +50,7 @@ export async function GET() {
}, },
}); });
} catch (error) { } catch (error) {
console.error("❌ Error in /api/auth/me:", error); console.error("❌ Error in /api/me:", error);
return NextResponse.json( return NextResponse.json(
{ success: false, message: "Internal server error", user: null }, { success: false, message: "Internal server error", user: null },
{ status: 500 } { status: 500 }

View File

@@ -42,7 +42,9 @@ function Page() {
const alamat = data?.informasiumum?.alamat || '-'; const alamat = data?.informasiumum?.alamat || '-';
const jam = data?.informasiumum?.jamOperasional || '-'; const jam = data?.informasiumum?.jamOperasional || '-';
const layananUnggulan = data?.layananunggulan?.content || ''; const layananUnggulan = data?.layananunggulan?.content || '';
const tenaga = data?.dokterdantenagamedis || null;
const fasilitasPendukungHtml = data?.fasilitaspendukung?.content || ''; const fasilitasPendukungHtml = data?.fasilitaspendukung?.content || '';
const tarif = (data?.tarifdanlayanan as TarifDanLayanan) || null;
const kontak = (data?.kontak as Kontak) || { const kontak = (data?.kontak as Kontak) || {
telepon: '(0361) 123456', telepon: '(0361) 123456',
whatsapp: '6289647037426', whatsapp: '6289647037426',
@@ -209,7 +211,7 @@ function Page() {
<Card radius="xl" p="lg" withBorder> <Card radius="xl" p="lg" withBorder>
<Stack gap="md"> <Stack gap="md">
<Title order={4}>Dokter & Tenaga Medis</Title> <Title order={4}>Dokter & Tenaga Medis</Title>
<Table highlightOnHover withTableBorder withColumnBorders aria-label="Tabel Dokter"> <Table highlightOnHover withTableBorder withColumnBorders stickyHeader stickyHeaderOffset={0} aria-label="Tabel Dokter">
<TableThead> <TableThead>
<TableTr> <TableTr>
<TableTh>Nama</TableTh> <TableTh>Nama</TableTh>
@@ -218,19 +220,12 @@ function Page() {
</TableTr> </TableTr>
</TableThead> </TableThead>
<TableTbody> <TableTbody>
{Array.isArray(data?.dokterdantenagamedis) && data.dokterdantenagamedis.length > 0 ? ( {tenaga ? (
data.dokterdantenagamedis.map((dokter: any) => ( <TableTr>
<TableTr key={dokter.id}> <TableTd><Group gap="xs"><IconUser size={16} /><Text>{tenaga?.name || '-'}</Text></Group></TableTd>
<TableTd> <TableTd>{tenaga?.specialist || '-'}</TableTd>
<Group gap="xs"> <TableTd>{tenaga?.jadwal || '-'}</TableTd>
<IconUser size={16} /> </TableTr>
<Text>{dokter.name || '-'}</Text>
</Group>
</TableTd>
<TableTd>{dokter.specialist || '-'}</TableTd>
<TableTd>{dokter.jadwal || '-'}</TableTd>
</TableTr>
))
) : ( ) : (
<TableTr> <TableTr>
<TableTd colSpan={3}> <TableTd colSpan={3}>
@@ -246,74 +241,72 @@ function Page() {
</Stack> </Stack>
</Card> </Card>
<Card radius="xl" p="lg" withBorder> <Card radius="xl" p="lg" withBorder>
<Stack gap="md"> <Stack gap="md">
<Title order={3}>Fasilitas Pendukung</Title> <Title order={3}>Fasilitas Pendukung</Title>
<Divider /> <Divider />
{fasilitasPendukungHtml ? ( {fasilitasPendukungHtml ? (
<Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: fasilitasPendukungHtml }} /> <Text fz="md" style={{wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: fasilitasPendukungHtml }} />
) : ( ) : (
<Paper withBorder radius="md" p="md"> <Paper withBorder radius="md" p="md">
<Group gap="sm"> <Group gap="sm">
<IconMoodEmpty /> <IconMoodEmpty />
<Text>Belum ada informasi fasilitas pendukung.</Text> <Text>Belum ada informasi fasilitas pendukung.</Text>
</Group>
</Paper>
)}
</Stack>
</Card>
<Card radius="xl" p="lg" withBorder>
<Stack gap="md">
<Title order={3}>Layanan & Tarif</Title>
<Divider />
<Table highlightOnHover withTableBorder withColumnBorders aria-label="Tabel Layanan dan Tarif">
<TableThead>
<TableTr>
<TableTh>Layanan</TableTh>
<TableTh>Tarif</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{Array.isArray(data?.tarifdanlayanan) && data.tarifdanlayanan.length > 0 ? (
data.tarifdanlayanan.map((item: any) => (
<TableTr key={item.id}>
<TableTd>{item.layanan || '-'}</TableTd>
<TableTd>{formatRupiah(item.tarif)}</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={2}>
<Group justify="center" gap="xs" c="dimmed">
<IconSearch size={18} />
<Text>Tidak ada data tarif.</Text>
</Group>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
{gratisBpjs && (
<Group gap="xs">
<ThemeIcon variant="light" radius="xl"><IconCheck size={18} /></ThemeIcon>
<Text fw={600}>Gratis dengan BPJS Kesehatan</Text>
</Group> </Group>
)} </Paper>
</Stack> )}
</Card> </Stack>
</Card>
<Card radius="xl" p="lg" withBorder>
<Stack gap="md">
<Title order={3}>Layanan & Tarif</Title>
<Divider />
<Table highlightOnHover withTableBorder withColumnBorders aria-label="Tabel Layanan dan Tarif">
<TableThead>
<TableTr>
<TableTh>Layanan</TableTh>
<TableTh>Tarif</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{tarif ? (
<TableTr>
<TableTd>{tarif?.layanan || '-'}</TableTd>
<TableTd>{formatRupiah(tarif?.tarif)}</TableTd>
</TableTr>
) : (
<TableTr>
<TableTd colSpan={2}>
<Group justify="center" gap="xs" c="dimmed">
<IconSearch size={18} />
<Text>Tidak ada data tarif.</Text>
</Group>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
{gratisBpjs && (
<Group gap="xs">
<ThemeIcon variant="light" radius="xl"><IconCheck size={18} /></ThemeIcon>
<Text fw={600}>Gratis dengan BPJS Kesehatan</Text>
</Group>
)}
</Stack>
</Card>
</Stack> </Stack>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Box> </Box>
<Box px={{ base: 'md', md: 100 }} pb="xl"> <Box px={{ base: 'md', md: 100 }} pb="xl">
<Paper radius="xl" p="lg" withBorder> <Paper radius="xl" p="lg" withBorder>
<Stack gap="md"> <Stack gap="md">
<Title order={3}>Prosedur Pendaftaran</Title> <Title order={3}>Prosedur Pendaftaran</Title>
<Divider /> <Divider />
{prosedur ? ( {prosedur ? (
<Text fz="md" style={{ wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: prosedur }} /> <Text fz="md" style={{wordBreak: "break-word", whiteSpace: "normal", lineHeight: 1.7 }} dangerouslySetInnerHTML={{ __html: prosedur }} />
) : ( ) : (
<Text fz="md" c="dimmed">Belum ada prosedur pendaftaran</Text> <Text fz="md" c="dimmed">Belum ada prosedur pendaftaran</Text>
)} )}

View File

@@ -151,7 +151,7 @@ function Page() {
variant="light" variant="light"
color="blue" color="blue"
leftSection={<IconDeviceImacCog size={16} />} leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/darmasaba/ppid/daftar-informasi-publik/${item.id}`)} onClick={() => router.push(`/darmasaba/ppid/daftar-informasi-publik-desa-darmasaba/${item.id}`)}
> >
Detail Detail
</Button> </Button>

View File

@@ -1,8 +1,13 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesProgress.tsx
'use client'; 'use client';
import colors from '@/con/colors';
import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core'; import { Box, Paper, Progress, Stack, Text, Title } from '@mantine/core';
import { APBDesData } from './types'; import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors';
function formatRupiah(value: number) { function formatRupiah(value: number) {
return new Intl.NumberFormat('id-ID', { return new Intl.NumberFormat('id-ID', {
@@ -12,33 +17,31 @@ function formatRupiah(value: number) {
}).format(value); }).format(value);
} }
interface APBDesProgressProps { function APBDesProgress() {
apbdesData: APBDesData; const state = useProxy(apbdes);
} const data = state.findMany.data || [];
function APBDesProgress({ apbdesData }: APBDesProgressProps) { // Ambil APBDes pertama (misalnya, jika hanya satu tahun ditampilkan)
// Return null if apbdesData is not available yet const apbdesItem = data[0]; // 👈 sesuaikan logika jika ada banyak APBDes
if (!apbdesData) {
return null; if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
} }
const items = apbdesData.items || []; const items = apbdesItem.items || [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode)); const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Kelompokkan berdasarkan tipe // Kelompokkan berdasarkan tipe
const pendapatanItems = sortedItems.filter(item => item.tipe === 'pendapatan'); const pendapatanItems = sortedItems.filter(item => item.tipe === 'pendapatan');
const belanjaItems = sortedItems.filter(item => item.tipe === 'belanja'); const belanjaItems = sortedItems.filter(item => item.tipe === 'belanja');
const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan'); const pembiayaanItems = sortedItems.filter(item => item.tipe === 'pembiayaan'); // jika ada
// Items without a type (should be filtered out from calculations)
const untypedItems = sortedItems.filter(item => !item.tipe);
if (untypedItems.length > 0) {
console.warn(`Found ${untypedItems.length} items without a type. These will be excluded from calculations.`);
}
// Hitung total per kategori // Hitung total per kategori
const calcTotal = (items: { anggaran: number; realisasi: number }[]) => { const calcTotal = (items: any[]) => {
const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0); const anggaran = items.reduce((sum, item) => sum + item.anggaran, 0);
const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0); const realisasi = items.reduce((sum, item) => sum + item.realisasi, 0);
const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; const persen = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
@@ -47,10 +50,10 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
const pendapatan = calcTotal(pendapatanItems); const pendapatan = calcTotal(pendapatanItems);
const belanja = calcTotal(belanjaItems); const belanja = calcTotal(belanjaItems);
const pembiayaan = calcTotal(pembiayaanItems); const pembiayaan = calcTotal(pembiayaanItems); // bisa kosong
// Render satu progress bar // Render satu progress bar
const renderProgress = (label: string, dataset: { realisasi: number; anggaran: number; persen: number }) => { const renderProgress = (label: string, dataset: any) => {
const isPembiayaan = label.includes('Pembiayaan'); const isPembiayaan = label.includes('Pembiayaan');
return ( return (
@@ -68,8 +71,8 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
root: { backgroundColor: '#d7e3f1' }, root: { backgroundColor: '#d7e3f1' },
section: { section: {
backgroundColor: isPembiayaan backgroundColor: isPembiayaan
? 'green' ? 'green' // warna hijau untuk pembiayaan
: colors['blue-button'], : colors['blue-button'], // biru untuk pendapatan/belanja
position: 'relative', position: 'relative',
'&::after': { '&::after': {
content: `'${dataset.persen.toFixed(2)}%'`, content: `'${dataset.persen.toFixed(2)}%'`,
@@ -99,7 +102,7 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
> >
<Stack gap="lg"> <Stack gap="lg">
<Title order={4} c={colors['blue-button']} ta="center"> <Title order={4} c={colors['blue-button']} ta="center">
Grafik Pelaksanaan APBDes Tahun {apbdesData.tahun} Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
</Title> </Title>
<Text ta="center" fw="bold" fz="sm" c="dimmed"> <Text ta="center" fw="bold" fz="sm" c="dimmed">
@@ -109,9 +112,97 @@ function APBDesProgress({ apbdesData }: APBDesProgressProps) {
{renderProgress('Pendapatan Desa', pendapatan)} {renderProgress('Pendapatan Desa', pendapatan)}
{renderProgress('Belanja Desa', belanja)} {renderProgress('Belanja Desa', belanja)}
{renderProgress('Pembiayaan Desa', pembiayaan)} {renderProgress('Pembiayaan Desa', pembiayaan)}
{pembiayaanItems.length > 0 && renderProgress('Pembiayaan Desa', pembiayaan)}
</Stack> </Stack>
</Paper> </Paper>
); );
} }
export default APBDesProgress; export default APBDesProgress;
// /* eslint-disable @typescript-eslint/no-explicit-any */
// 'use client';
// import { Box, Paper, Stack, Text, Title } from '@mantine/core';
// import { BarChart } from '@mantine/charts';
// import { useProxy } from 'valtio/utils';
// import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
// import colors from '@/con/colors';
// function APBDesProgress() {
// const state = useProxy(apbdes);
// const data = state.findMany.data || [];
// const apbdesItem = data[0];
// if (!apbdesItem) {
// return (
// <Box py="md" px={{ base: 'md', md: 100 }}>
// <Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
// </Box>
// );
// }
// const items = apbdesItem.items || [];
// const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// const pendapatanItems = sortedItems.filter(i => i.tipe === 'pendapatan');
// const belanjaItems = sortedItems.filter(i => i.tipe === 'belanja');
// const pembiayaanItems = sortedItems.filter(i => i.tipe === 'pembiayaan');
// const total = (rows: any[]) => {
// const anggaran = rows.reduce((s, i) => s + i.anggaran, 0);
// const realisasi = rows.reduce((s, i) => s + i.realisasi, 0);
// return anggaran === 0 ? 0 : (realisasi / anggaran) * 100;
// };
// const chartData = [
// { name: 'Pendapatan', persen: total(pendapatanItems) },
// { name: 'Belanja', persen: total(belanjaItems) },
// ];
// if (pembiayaanItems.length > 0) {
// chartData.push({ name: 'Pembiayaan', persen: total(pembiayaanItems) });
// }
// return (
// <Paper
// mx={{ base: 'md', md: 100 }}
// p="xl"
// radius="md"
// shadow="sm"
// withBorder
// bg={colors['white-1']}
// >
// <Stack gap="lg">
// <Title order={4} c={colors['blue-button']} ta="center">
// Grafik Pelaksanaan APBDes Tahun {apbdesItem.tahun}
// </Title>
// <Text ta="center" fw="bold" fz="sm" c="dimmed">
// Persentase Realisasi (%) dari Anggaran
// </Text>
// <BarChart
// h={200}
// data={chartData}
// orientation="vertical"
// dataKey="name"
// barProps={{ radius: 6 }}
// series={[
// {
// name: 'persen',
// label: 'Persentase',
// color: colors['blue-button'],
// },
// ]}
// yAxisProps={{
// domain: [0, 100],
// }}
// valueFormatter={(v) => `${v.toFixed(1)}%`}
// />
// </Stack>
// </Paper>
// );
// }
// export default APBDesProgress;

View File

@@ -1,8 +1,30 @@
// src/app/admin/(dashboard)/landing-page/APBDes/APBDesTable.tsx
'use client'; 'use client';
import { Box, Paper, Table, Text, Title, Badge, Group } from '@mantine/core'; import { Box, Paper, Table, Text, Title, Badge, Group } from '@mantine/core';
import { useProxy } from 'valtio/utils';
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
import colors from '@/con/colors'; import colors from '@/con/colors';
import { APBDesData } from './types';
interface APBDesItem {
id: string;
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
}
interface APBDesData {
id: string;
tahun: number;
items: APBDesItem[];
image?: { id: string; url: string } | null;
file?: { id: string; url: string } | null;
}
// Helper: Format Rupiah, tapi jika 0 → tampilkan '-' // Helper: Format Rupiah, tapi jika 0 → tampilkan '-'
function formatRupiahOrEmpty(value: number): string { function formatRupiahOrEmpty(value: number): string {
@@ -29,12 +51,22 @@ function getIndent(level: number) {
}; };
} }
interface APBDesTableProps { function APBDesTable() {
apbdesData: APBDesData; const state = useProxy(apbdes);
} const data = state.findMany.data || [];
function APBDesTable({ apbdesData }: APBDesTableProps) { // Get the first APBDes item
const items = Array.isArray(apbdesData.items) ? apbdesData.items : []; const apbdesItem = data[0] as unknown as APBDesData | undefined;
if (!apbdesItem) {
return (
<Box py="md" px={{ base: 'md', md: 100 }}>
<Text c="dimmed">Belum ada data APBDes untuk ditampilkan.</Text>
</Box>
);
}
const items = Array.isArray(apbdesItem.items) ? apbdesItem.items : [];
const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode)); const sortedItems = [...items].sort((a, b) => a.kode.localeCompare(b.kode));
// Calculate totals // Calculate totals
@@ -44,13 +76,13 @@ function APBDesTable({ apbdesData }: APBDesTableProps) {
const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0; const totalPersentase = totalAnggaran > 0 ? (totalRealisasi / totalAnggaran) * 100 : 0;
return ( return (
<Box pt={"xs"} pb="md" px={{ base: 'md', md: 100 }}> <Box py="md" px={{ base: 'md', md: 100 }}>
<Title order={4} c={colors['blue-button']} mb="sm"> <Title order={4} c={colors['blue-button']} mb="sm">
Rincian APBDes Tahun {apbdesData.tahun} Rincian APBDes Tahun {apbdesItem.tahun}
</Title> </Title>
<Paper withBorder radius="md" shadow="xs" p="md"> <Paper withBorder radius="md" shadow="xs" p="md">
<Box style={{ overflowY: 'auto' }}> <Box style={{overflowY: 'auto' }}>
<Table withColumnBorders highlightOnHover> <Table withColumnBorders highlightOnHover>
<Table.Thead bg="#2c5f78"> <Table.Thead bg="#2c5f78">
<Table.Tr> <Table.Tr>
@@ -77,7 +109,9 @@ function APBDesTable({ apbdesData }: APBDesTableProps) {
<Table.Td style={getIndent(item.level)}> <Table.Td style={getIndent(item.level)}>
<Group gap="xs" align="flex-start"> <Group gap="xs" align="flex-start">
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text> <Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
<Text fz="sm">{item.uraian}</Text> <Text fz="sm" >
{item.uraian}
</Text>
</Group> </Group>
</Table.Td> </Table.Td>
<Table.Td ta="right">{formatRupiahOrEmpty(item.anggaran)}</Table.Td> <Table.Td ta="right">{formatRupiahOrEmpty(item.anggaran)}</Table.Td>

View File

@@ -1,42 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
export type APBDesTipe = 'pendapatan' | 'belanja' | 'pembiayaan';
export function isAPBDesTipe(tipe: string | null | undefined): tipe is APBDesTipe {
return tipe === 'pendapatan' || tipe === 'belanja' || tipe === 'pembiayaan';
}
export interface APBDesItem {
id: string;
kode: string;
uraian: string;
anggaran: number;
realisasi: number;
selisih: number;
persentase: number;
level: number;
tipe?: APBDesTipe | null;
// Additional fields from API
createdAt?: Date;
updatedAt?: Date;
deletedAt?: Date | null;
isActive?: boolean;
apbdesId?: string;
}
export interface APBDesData {
id: string;
tahun: number | null;
items: APBDesItem[];
image?: { id: string; url: string } | null;
file?: { id: string; url: string } | null;
}
export function transformAPBDesData(data: any): APBDesData {
return {
...data,
items: data.items.map((item: any) => ({
...item,
tipe: isAPBDesTipe(item.tipe) ? item.tipe : null
}))
};
}

View File

@@ -4,21 +4,19 @@
import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa' import PendapatanAsliDesa from '@/app/admin/(dashboard)/_state/ekonomi/PADesa'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes' import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import colors from '@/con/colors' import colors from '@/con/colors'
import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, Select, SimpleGrid, Stack, Text, Title } from '@mantine/core' import { ActionIcon, BackgroundImage, Box, Center, Container, Group, Loader, SimpleGrid, Stack, Text, Title } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react' import { IconDownload } from '@tabler/icons-react'
import { Link } from 'next-view-transitions' import { Link } from 'next-view-transitions'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils' import { useProxy } from 'valtio/utils'
import BackButton from '../../(pages)/desa/layanan/_com/BackButto' import BackButton from '../../(pages)/desa/layanan/_com/BackButto'
import APBDesTable from './lib/apbDesaTable'
import APBDesProgress from './lib/apbDesaProgress' import APBDesProgress from './lib/apbDesaProgress'
import { transformAPBDesData } from './lib/types' import APBDesTable from './lib/apbDesaTable'
function Page() { function Page() {
const state = useProxy(apbdes) const state = useProxy(apbdes)
const paDesaState = useProxy(PendapatanAsliDesa.ApbDesa) const paDesaState = useProxy(PendapatanAsliDesa.ApbDesa)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [selectedYear, setSelectedYear] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
@@ -36,23 +34,6 @@ function Page() {
const dataAPBDes = state.findMany.data || [] const dataAPBDes = state.findMany.data || []
// Buat daftar tahun unik dari data
const years = Array.from(new Set(dataAPBDes.map((item: any) => item.tahun)))
.sort((a, b) => b - a) // urutkan descending
.map(year => ({ value: year.toString(), label: `Tahun ${year}` }))
// Pilih tahun pertama sebagai default jika belum ada yang dipilih
useEffect(() => {
if (years.length > 0 && !selectedYear) {
setSelectedYear(years[0].value)
}
}, [years, selectedYear])
// Transform and filter data based on selected year
const currentApbdes = dataAPBDes.length > 0
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
: null
return ( return (
<Stack pos="relative" bg={colors.Bg} py="xl" gap={32}> <Stack pos="relative" bg={colors.Bg} py="xl" gap={32}>
<Box px={{ base: 'md', md: 100 }}> <Box px={{ base: 'md', md: 100 }}>
@@ -113,31 +94,8 @@ function Page() {
))} ))}
</SimpleGrid> </SimpleGrid>
)} )}
{/* 🔥 COMBOBOX UNTUK PILIH TAHUN */} <APBDesTable />
<Box px={{ base: 'md', md: 100 }}> <APBDesProgress />
<Select
label="Pilih Tahun APBDes"
placeholder="Pilih tahun"
value={selectedYear}
onChange={setSelectedYear}
data={years}
w={{ base: '100%', sm: 200 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
/>
</Box>
{/* ❗ Pass currentApbdes ke komponen anak */}
{currentApbdes ? (
<>
<APBDesTable apbdesData={currentApbdes} />
<APBDesProgress apbdesData={currentApbdes} />
</>
) : (
<Box px={{ base: 'md', md: 100 }} py="md">
<Text c="dimmed">Tidak ada data APBDes untuk tahun yang dipilih.</Text>
</Box>
)}
</Stack> </Stack>
) )
} }

View File

@@ -1,11 +1,9 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
'use client' 'use client'
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes' import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes'
import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress' import APBDesProgress from '@/app/darmasaba/(tambahan)/apbdes/lib/apbDesaProgress'
import { transformAPBDesData } from '@/app/darmasaba/(tambahan)/apbdes/lib/types'
import colors from '@/con/colors' import colors from '@/con/colors'
import { ActionIcon, BackgroundImage, Box, Button, Center, Group, Loader, Select, SimpleGrid, Stack, Text } from '@mantine/core' import { ActionIcon, BackgroundImage, Box, Button, Center, Flex, Group, Loader, SimpleGrid, Stack, Text } from '@mantine/core'
import { IconDownload } from '@tabler/icons-react' import { IconDownload } from '@tabler/icons-react'
import Link from 'next/link' import Link from 'next/link'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@@ -14,7 +12,6 @@ import { useProxy } from 'valtio/utils'
function Apbdes() { function Apbdes() {
const state = useProxy(apbdes) const state = useProxy(apbdes)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [selectedYear, setSelectedYear] = useState<string | null>(null)
const textHeading = { const textHeading = {
title: 'APBDes', title: 'APBDes',
@@ -35,24 +32,6 @@ function Apbdes() {
loadData() loadData()
}, []) }, [])
const dataAPBDes = state.findMany.data || []
const years = Array.from(new Set(dataAPBDes.map((item: any) => item.tahun)))
.sort((a, b) => b - a) // urutkan descending
.map(year => ({ value: year.toString(), label: `Tahun ${year}` }))
// Pilih tahun pertama sebagai default jika belum ada yang dipilih
useEffect(() => {
if (years.length > 0 && !selectedYear) {
setSelectedYear(years[0].value)
}
}, [years, selectedYear])
// Transform and filter data based on selected year
const currentApbdes = dataAPBDes.length > 0
? transformAPBDesData(dataAPBDes.find(item => item?.tahun?.toString() === selectedYear) || dataAPBDes[0])
: null
const data = (state.findMany.data || []).slice(0, 3) const data = (state.findMany.data || []).slice(0, 3)
return ( return (
@@ -81,30 +60,8 @@ function Apbdes() {
</Button> </Button>
</Group> </Group>
{/* 🔥 COMBOBOX UNTUK PILIH TAHUN */} {/* Chart */}
<Box px={{ base: 'md', md: 100 }}> <APBDesProgress />
<Select
label="Pilih Tahun APBDes"
placeholder="Pilih tahun"
value={selectedYear}
onChange={setSelectedYear}
data={years}
w={{ base: '100%', sm: 200 }}
searchable
clearable
nothingFoundMessage="Tidak ada tahun tersedia"
/>
</Box>
{currentApbdes ? (
<>
<APBDesProgress apbdesData={currentApbdes} />
</>
) : (
<Box px={{ base: 'md', md: 100 }} py="md">
<Text c="dimmed">Tidak ada data APBDes untuk tahun yang dipilih.</Text>
</Box>
)}
<SimpleGrid mx={{ base: 'md', md: 100 }} cols={{ base: 1, sm: 3 }} spacing="lg" pb={"xl"}> <SimpleGrid mx={{ base: 'md', md: 100 }} cols={{ base: 1, sm: 3 }} spacing="lg" pb={"xl"}>
{loading ? ( {loading ? (
@@ -133,7 +90,7 @@ function Apbdes() {
style={{ overflow: 'hidden' }} style={{ overflow: 'hidden' }}
> >
<Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} /> <Box pos="absolute" inset={0} bg="rgba(0,0,0,0.45)" style={{ borderRadius: 16 }} />
<Stack gap={"xs"} justify="space-between" h="100%" p="xl" pos="relative"> <Stack justify="space-between" h="100%" p="xl" pos="relative">
<Text <Text
c="white" c="white"
fw={600} fw={600}
@@ -152,20 +109,7 @@ function Apbdes() {
> >
{v.jumlah} {v.jumlah}
</Text> </Text>
<Center> <Group justify="center">
<ActionIcon
component={Link}
href={v.file?.link || ''}
radius="xl"
size="xl"
variant="gradient"
gradient={{ from: '#1C6EA4', to: '#1C6EA4' }}
>
<IconDownload size={20} color="white" />
</ActionIcon>
</Center>
{/* <Group justify="center">
<ActionIcon <ActionIcon
component={Link} component={Link}
href={v.file?.link || ''} href={v.file?.link || ''}
@@ -174,18 +118,18 @@ function Apbdes() {
variant="gradient" variant="gradient"
gradient={{ from: '#1C6EA4', to: '#1C6EA4' }} gradient={{ from: '#1C6EA4', to: '#1C6EA4' }}
> >
<Group align="center" gap="xs" px="md" py={6}> <Flex align="center" gap="xs" px="md" py={6}>
<IconDownload size={25} color="white" /> <IconDownload size={18} color="white" />
</Group> </Flex>
</ActionIcon> </ActionIcon>
</Group> */} </Group>
</Stack> </Stack>
</BackgroundImage> </BackgroundImage>
)) ))
)} )}
</SimpleGrid> </SimpleGrid>
</Stack> </Stack>
) )
} }

View File

@@ -1,9 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ import { ActionIcon, Card, Flex, Image, Text, Tooltip } from "@mantine/core";
import { sosmedMap } from "@/app/admin/(dashboard)/landing-page/profil/_lib/sosmed";
import colors from "@/con/colors";
import { ActionIcon, Box, Card, Flex, Image, Text, Tooltip } from "@mantine/core";
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { useTransitionRouter } from "next-view-transitions"; import { useTransitionRouter } from "next-view-transitions";
import { IconBrandInstagram, IconBrandFacebook, IconBrandTwitter, IconWorld } from "@tabler/icons-react";
function SosmedView({ function SosmedView({
data, data,
@@ -12,12 +10,17 @@ function SosmedView({
}) { }) {
const router = useTransitionRouter(); const router = useTransitionRouter();
const getIconSource = (item: any) => { const fallbackIcon = (platform?: string) => {
if (item.image?.link) return item.image.link; switch (platform?.toLowerCase()) {
if (item.icon && sosmedMap[item.icon as keyof typeof sosmedMap]?.src) { case "instagram":
return sosmedMap[item.icon as keyof typeof sosmedMap].src; return <IconBrandInstagram size={22} />;
case "facebook":
return <IconBrandFacebook size={22} />;
case "twitter":
return <IconBrandTwitter size={22} />;
default:
return <IconWorld size={22} />;
} }
return null;
}; };
return ( return (
@@ -41,24 +44,18 @@ function SosmedView({
boxShadow: "0 0 12px rgba(28, 110, 164, 0.6)", boxShadow: "0 0 12px rgba(28, 110, 164, 0.6)",
}} }}
> >
{(() => { {item.image?.link ? (
const src = getIconSource(item); <Image
src={item.image.link}
if (src) { alt={item.name || "ikon"}
return ( w={24}
<Image h={24}
loading="lazy" fit="contain"
src={src} loading="lazy"
alt={item.name} />
w={24} ) : (
h={24} fallbackIcon(item.name)
fit="contain" )}
/>
);
}
return <Box bg={colors['blue-button']} w="100%" h="100%" />;
})()}
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
)) ))

View File

@@ -1,4 +1,4 @@
const getDetailUrl = (item: { type?: string; id: string | number;[key: string]: unknown }) => { const getDetailUrl = (item: { type?: string; id: string | number; [key: string]: unknown }) => {
const { type, id, kategori } = item; const { type, id, kategori } = item;
const map: Record<string, (id: string | number, kategori?: string) => string> = { const map: Record<string, (id: string | number, kategori?: string) => string> = {
programinovasi: (id) => `/darmasaba/program-inovasi/${id}`, programinovasi: (id) => `/darmasaba/program-inovasi/${id}`,
@@ -30,8 +30,6 @@ const getDetailUrl = (item: { type?: string; id: string | number;[key: string]:
penghargaan: () => '/darmasaba/desa/penghargaan', penghargaan: () => '/darmasaba/desa/penghargaan',
posyandu: (id) => `/darmasaba/kesehatan/posyandu/${id}`, posyandu: (id) => `/darmasaba/kesehatan/posyandu/${id}`,
fasilitasKesehatan: () => '/darmasaba/kesehatan/data-kesehatan-warga', fasilitasKesehatan: () => '/darmasaba/kesehatan/data-kesehatan-warga',
dokterDanTenagaMedis: () => '/darmasaba/kesehatan/data-kesehatan-warga',
tarifDanLayanan: () => '/darmasaba/kesehatan/data-kesehatan-warga',
jadwalKegiatan: () => '/darmasaba/kesehatan/data-kesehatan-warga', jadwalKegiatan: () => '/darmasaba/kesehatan/data-kesehatan-warga',
artikelKesehatan: () => '/darmasaba/kesehatan/data-kesehatan-warga', artikelKesehatan: () => '/darmasaba/kesehatan/data-kesehatan-warga',
puskesmas: () => '/darmasaba/kesehatan/puskesmas', puskesmas: () => '/darmasaba/kesehatan/puskesmas',
@@ -84,7 +82,7 @@ const getDetailUrl = (item: { type?: string; id: string | number;[key: string]:
jenisProgramYangDiselenggarakan: () => '/darmasaba/pendidikan/pendidikan-non-formal', jenisProgramYangDiselenggarakan: () => '/darmasaba/pendidikan/pendidikan-non-formal',
dataPerpustakaan: () => '/darmasaba/pendidikan/perpustakaan-digital/semua', dataPerpustakaan: () => '/darmasaba/pendidikan/perpustakaan-digital/semua',
dataPendidikan: () => '/darmasaba/pendidikan/data-pendidikan', dataPendidikan: () => '/darmasaba/pendidikan/data-pendidikan',
}; };
if (type && map[type]) return map[type](id, kategori as string | undefined); if (type && map[type]) return map[type](id, kategori as string | undefined);

View File

@@ -1,173 +0,0 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Syarat & Ketentuan Penggunaan HIPMI Badung Connect</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #1e293b;
background-color: #f8fafc;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
background-color: white;
min-height: 100vh;
}
h1 {
font-size: 2rem;
font-weight: 700;
color: #1e3a5f;
margin-bottom: 1.5rem;
line-height: 1.3;
}
h2 {
font-size: 1.5rem;
font-weight: 700;
color: #1e3a5f;
margin-top: 2.5rem;
margin-bottom: 1rem;
}
p {
margin-bottom: 1rem;
color: #334155;
}
strong {
font-weight: 600;
color: #1e293b;
}
ul {
margin-left: 1.5rem;
margin-bottom: 1.5rem;
}
li {
margin-bottom: 0.5rem;
color: #334155;
}
.intro {
margin-bottom: 2rem;
padding: 1.25rem;
background-color: #f1f5f9;
border-radius: 8px;
border-left: 4px solid #1e3a5f;
}
.footer {
margin-top: 3rem;
padding-top: 2rem;
border-top: 1px solid #e2e8f0;
text-align: center;
color: #64748b;
font-size: 0.875rem;
}
@media (max-width: 768px) {
.container {
padding: 24px 16px;
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.25rem;
margin-top: 2rem;
}
}
@media (max-width: 480px) {
h1 {
font-size: 1.25rem;
}
h2 {
font-size: 1.125rem;
}
ul {
margin-left: 1.25rem;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Syarat & Ketentuan Penggunaan HIPMI Badung Connect</h1>
<div class="intro">
<p>Dengan menggunakan aplikasi <strong>HIPMI Badung Connect</strong> ("Aplikasi"), Anda setuju untuk mematuhi dan terikat oleh syarat dan ketentuan berikut. Jika Anda tidak setuju dengan ketentuan ini, harap jangan gunakan Aplikasi.</p>
</div>
<h2>1. Definisi</h2>
<p><strong>HIPMI Badung Connect</strong> adalah platform digital resmi untuk anggota Himpunan Pengusaha Muda Indonesia (HIPMI) Kabupaten Badung, yang bertujuan memfasilitasi jaringan, kolaborasi, dan pertumbuhan bisnis para pengusaha muda.</p>
<h2>2. Larangan Konten Tidak Pantas</h2>
<p>Anda <strong>dilarang keras</strong> memposting, mengirim, membagikan, atau mengunggah konten apa pun yang mengandung:</p>
<ul>
<li>Ujaran kebencian, diskriminasi, atau konten SARA (Suku, Agama, Ras, Antar-golongan)</li>
<li>Pornografi, konten seksual eksplisit, atau gambar tidak senonoh</li>
<li>Ancaman, pelecehan, bullying, atau perilaku melecehkan</li>
<li>Informasi palsu, hoaks, spam, atau konten menyesatkan</li>
<li>Konten ilegal, melanggar hukum, atau melanggar hak kekayaan intelektual pihak lain</li>
<li>Promosi narkoba, perjudian, atau aktivitas ilegal lainnya</li>
</ul>
<h2>3. Tanggung Jawab Pengguna</h2>
<p>Anda bertanggung jawab penuh atas setiap konten yang Anda unggah atau bagikan melalui fitur-fitur berikut:</p>
<ul>
<li>Profil (bio, foto, portofolio)</li>
<li>Forum diskusi</li>
<li>Chat pribadi atau grup</li>
<li>Lowongan kerja, investasi, dan donasi</li>
</ul>
<p>Konten yang melanggar ketentuan ini dapat dihapus kapan saja tanpa pemberitahuan.</p>
<h2>4. Tindakan terhadap Pelanggaran</h2>
<p>Jika kami menerima laporan atau menemukan konten yang melanggar ketentuan ini, kami akan:</p>
<ul>
<li>Segera menghapus konten tersebut</li>
<li>Memberikan peringatan atau memblokir akun pengguna</li>
<li>Dalam kasus berat, melaporkan ke pihak berwajib sesuai hukum yang berlaku</li>
</ul>
<p>Tim kami berkomitmen untuk menanggapi laporan konten tidak pantas <strong>dalam waktu 24 jam</strong>.</p>
<h2>5. Mekanisme Pelaporan</h2>
<p>Anda dapat melaporkan konten atau pengguna yang mencurigakan melalui:</p>
<ul>
<li>Tombol <strong>"Laporkan"</strong> di setiap posting forum atau pesan chat</li>
<li>Tombol <strong>"Blokir Pengguna"</strong> di profil pengguna</li>
</ul>
<p>Setiap laporan akan ditangani secara rahasia dan segera.</p>
<h2>6. Perubahan Ketentuan</h2>
<p>Kami berhak memperbarui Syarat & Ketentuan ini sewaktu-waktu. Versi terbaru akan dipublikasikan di halaman ini dengan tanggal revisi yang diperbarui.</p>
<h2>7. Kontak</h2>
<p>Jika Anda memiliki pertanyaan tentang ketentuan ini, silakan hubungi kami di:<br>
<strong>bip.baliinteraktifperkasa@gmail.com</strong></p>
<div class="footer">
© 2025 Bali Interaktif Perkasa. All rights reserved.
</div>
</div>
</body>
</html>

View File

@@ -1,105 +1,31 @@
/* styles/globals.css */ /* styles/globals.css */
/* ===================================
1. IMPORT CSS LIBRARIES
=================================== */
@import "@mantine/carousel/styles.css";
@import "@mantine/dropzone/styles.css";
@import "@mantine/charts/styles.css";
@import "@mantine/dates/styles.css";
@import "@mantine/tiptap/styles.css";
@import "animate.css";
@import "react-simple-toasts/dist/style.css";
@import "react-simple-toasts/dist/theme/dark.css";
@import "primereact/resources/themes/lara-light-blue/theme.css";
@import "primereact/resources/primereact.min.css";
@import "primeicons/primeicons.css";
/* ===================================
2. FONT FACE - OPTIMIZED
=================================== */
@font-face { @font-face {
font-family: 'San Francisco'; font-family: 'San Francisco';
src: url('/assets/fonts/font.otf') format('opentype'); src: url('/assets/fonts/font.otf') format('opentype');
font-weight: normal; font-weight: normal;
font-style: normal; font-style: normal;
font-display: swap; /* ✅ TAMBAHKAN INI - Penting untuk PageSpeed! */
} }
/* ===================================
3. RESET & BASE STYLES
=================================== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%; /* Prevent font scaling in landscape */
-moz-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
font-family: 'San Francisco', -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ===================================
4. GLASS EFFECTS - OPTIMIZED
=================================== */
.glass { .glass {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
position: fixed; position: fixed;
z-index: 50; z-index: 50;
width: 100%; width: 100%;
height: 100vh; height: 100vh;
will-change: transform; /* ✅ Hardware acceleration */
} }
.glass2 { .glass2 {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
backdrop-filter: blur(40px);
position: fixed; position: fixed;
z-index: 1; z-index: 1;
will-change: transform; /* ✅ Hardware acceleration */
} }
.glass3 { .glass3 {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px); -webkit-backdrop-filter: blur(40px);
will-change: transform; /* ✅ Hardware acceleration */ backdrop-filter: blur(40px);
} }
/* ===================================
5. PERFORMANCE OPTIMIZATION
=================================== */
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
height: auto;
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -1,5 +1,20 @@
// Import styles of packages that you've installed.
// All packages except `@mantine/hooks` require styles imports
import "@mantine/carousel/styles.css";
import "@mantine/core/styles.css"; import "@mantine/core/styles.css";
import '@mantine/dropzone/styles.css';
import "animate.css";
import 'react-simple-toasts/dist/style.css';
import 'react-simple-toasts/dist/theme/dark.css';
import "./globals.css"; import "./globals.css";
import '@mantine/charts/styles.css';
import '@mantine/dates/styles.css';
import '@mantine/tiptap/styles.css';
import "primereact/resources/themes/lara-light-blue/theme.css";
import "primereact/resources/primereact.min.css";
import "primeicons/primeicons.css";
import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient"; import LoadDataFirstClient from "@/app/darmasaba/_com/LoadDataFirstClient";
import { import {
@@ -8,83 +23,19 @@ import {
createTheme, createTheme,
mantineHtmlProps, mantineHtmlProps,
} from "@mantine/core"; } from "@mantine/core";
import { Metadata, Viewport } from "next";
import { ViewTransitions } from "next-view-transitions"; import { ViewTransitions } from "next-view-transitions";
import { ToastContainer } from "react-toastify"; import { ToastContainer } from "react-toastify";
// ✅ Pisahkan viewport ke export tersendiri export const metadata = {
export const viewport: Viewport = { title: "Desa Darmasaba",
width: "device-width", description: "Desa Darmasaba Kabupaten Badung",
initialScale: 1,
maximumScale: 5,
};
export const metadata: Metadata = {
// ✅ Tambahkan metadataBase
metadataBase: new URL("https://cld-dkr-staging-desa-darmasaba.wibudev.com"),
title: {
default: "Desa Darmasaba",
template: "%s | Desa Darmasaba",
},
description: "Website resmi Desa Darmasaba, Kabupaten Badung, Bali. Informasi layanan publik, berita, dan profil desa.",
// ❌ HAPUS viewport dari sini
keywords: [
"desa darmasaba",
"darmasaba",
"badung",
"bali",
"desa",
"pemerintah desa",
"layanan publik",
"abang batan desa",
],
authors: [{ name: "Pemerintah Desa Darmasaba" }],
creator: "Desa Darmasaba",
publisher: "Desa Darmasaba",
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
icons: {
icon: "/assets/images/darmasaba-icon.png",
apple: "/assets/images/darmasaba-icon.png",
},
manifest: "/manifest.json",
openGraph: {
type: "website",
locale: "id_ID",
url: "https://cld-dkr-staging-desa-darmasaba.wibudev.com",
siteName: "Desa Darmasaba",
title: "Desa Darmasaba - Kabupaten Badung, Bali",
description: "Website resmi Desa Darmasaba, Kabupaten Badung, Bali. Informasi layanan publik, berita, dan profil desa.",
images: [
{
url: "/assets/images/darmasaba-icon.png",
width: 1200,
height: 630,
alt: "Desa Darmasaba",
},
],
},
category: "government",
other: {
"msapplication-TileColor": "#ffffff",
"theme-color": "#ffffff",
},
}; };
const theme = createTheme({ const theme = createTheme({
fontFamily: "San Francisco, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, sans-serif", fontFamily:
fontFamilyMonospace: "SFMono-Regular, Menlo, Monaco, Consolas, monospace", "San Francisco, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, sans-serif",
fontFamilyMonospace:
"SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace",
headings: { fontFamily: "San Francisco, sans-serif" }, headings: { fontFamily: "San Francisco, sans-serif" },
}); });
@@ -95,23 +46,26 @@ export default function RootLayout({
}) { }) {
return ( return (
<ViewTransitions> <ViewTransitions>
<html lang="id" {...mantineHtmlProps}> <html lang="en" {...mantineHtmlProps}>
<head> <head>
<meta charSet="utf-8" />
<ColorSchemeScript /> <ColorSchemeScript />
<link
rel="icon"
href="/assets/images/darmasaba-icon.png"
sizes="any"
/>
</head> </head>
<body> <body>
<MantineProvider theme={theme}> <MantineProvider theme={theme}>
{children} {children}
<LoadDataFirstClient />
<ToastContainer
position="bottom-center"
hideProgressBar
style={{ zIndex: 9999 }}
/>
</MantineProvider> </MantineProvider>
<ToastContainer position="bottom-center" hideProgressBar style={{
zIndex: 9999
}} />
</body> </body>
<LoadDataFirstClient />
</html> </html>
</ViewTransitions> </ViewTransitions>
); );
} }

View File

@@ -1,102 +0,0 @@
import { Box, Container, Divider, List, ListItem, Paper, Stack, Text, Title } from '@mantine/core';
import React from 'react';
function Page() {
return (
<Container size="md" py={40}>
<Stack gap="xl">
<Title order={1} size="h1" fw={700} c="blue.9">
Syarat & Ketentuan Penggunaan Admin Desa Darmasaba
</Title>
<Paper p="lg" radius="md" withBorder bg="gray.0" style={{ borderLeft: '4px solid #1e3a5f' }}>
<Text c="gray.8">
Dengan menggunakan website <Text component="span" fw={600}>Admin Desa Darmasaba</Text> (&quot;Website&quot;),
Anda setuju untuk mematuhi dan terikat oleh syarat dan ketentuan berikut. Jika Anda tidak setuju
dengan ketentuan ini, harap jangan gunakan Website.
</Text>
</Paper>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
1. Definisi
</Title>
<Text c="gray.7">
<Text component="span" fw={600}>Admin Desa Darmasaba</Text> adalah website resmi untuk Admin Desa Darmasaba, yang bertujuan
menambahkan, menghapus, dan mengedit konten desa ke dalam website.
</Text>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
2. Larangan Konten Tidak Pantas
</Title>
<Text c="gray.7" mb="md">
Anda <Text component="span" fw={600}>dilarang keras</Text> menambahkan, menghapus, dan mengedit konten desa apa pun yang mengandung:
</Text>
<List spacing="xs" c="gray.7">
<ListItem>Ujaran kebencian, diskriminasi, atau konten SARA (Suku, Agama, Ras, Antar-golongan)</ListItem>
<ListItem>Pornografi, konten seksual eksplisit, atau gambar tidak senonoh</ListItem>
<ListItem>Ancaman, pelecehan, bullying, atau perilaku melecehkan</ListItem>
<ListItem>Informasi palsu, hoaks, spam, atau konten menyesatkan</ListItem>
<ListItem>Konten ilegal, melanggar hukum, atau melanggar hak kekayaan intelektual pihak lain</ListItem>
<ListItem>Promosi narkoba, perjudian, atau aktivitas ilegal lainnya</ListItem>
</List>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
3. Tanggung Jawab Pengguna
</Title>
<List spacing="xs" c="gray.7">
<ListItem>Anda bertanggung jawab penuh atas setiap konten yang Anda unggah atau bagikan.</ListItem>
<ListItem>Konten yang melanggar ketentuan ini dapat dihapus kapan saja tanpa pemberitahuan.</ListItem>
</List>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
4. Tindakan terhadap Pelanggaran
</Title>
<Text c="gray.7" mb="md">
Jika kami menerima laporan atau menemukan konten yang melanggar ketentuan ini, kami akan:
</Text>
<List spacing="xs" c="gray.7">
<ListItem>Segera menghapus konten tersebut</ListItem>
<ListItem>Menghapus akun pengguna</ListItem>
<ListItem>Dalam kasus berat, melaporkan ke pihak berwajib sesuai hukum yang berlaku</ListItem>
</List>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
5. Perubahan Ketentuan
</Title>
<Text c="gray.7">
Kami berhak memperbarui Syarat & Ketentuan ini sewaktu-waktu. Versi terbaru akan dipublikasikan di
halaman ini dengan tanggal revisi yang diperbarui.
</Text>
</Box>
<Box>
<Title order={2} size="h2" fw={700} c="blue.9" mb="md">
6. Kontak
</Title>
<Text c="gray.7">
Jika Anda memiliki pertanyaan tentang ketentuan ini, silakan hubungi kami di:
</Text>
<Text c="gray.7" fw={600} mt="xs">
bip.baliinteraktifperkasa@gmail.com
</Text>
</Box>
<Divider my="xl" />
<Text ta="center" c="gray.6" size="sm">
© 2025 Bali Interaktif Perkasa. All rights reserved.
</Text>
</Stack>
</Container>
);
}
export default Page;

View File

@@ -16,9 +16,7 @@ import { useEffect, useState } from 'react';
import { authStore } from '@/store/authStore'; // ✅ integrasi authStore import { authStore } from '@/store/authStore'; // ✅ integrasi authStore
async function fetchUser() { async function fetchUser() {
const res = await fetch('/api/auth/me', { const res = await fetch('/api/me');
credentials: 'include'
});
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text();
throw new Error(`HTTP ${res.status}: ${text}`); throw new Error(`HTTP ${res.status}: ${text}`);
@@ -34,7 +32,6 @@ export default function WaitingRoom() {
const [retryCount, setRetryCount] = useState(0); const [retryCount, setRetryCount] = useState(0);
const MAX_RETRIES = 2; const MAX_RETRIES = 2;
useEffect(() => { useEffect(() => {
let isMounted = true; let isMounted = true;
let interval: ReturnType<typeof setInterval>; let interval: ReturnType<typeof setInterval>;
@@ -80,7 +77,7 @@ export default function WaitingRoom() {
// Force a session refresh // Force a session refresh
try { try {
const res = await fetch('/api/auth/refresh-session', { const res = await fetch('/api/refresh-session', {
method: 'POST', method: 'POST',
credentials: 'include' credentials: 'include'
}); });