Compare commits

...

3 Commits

Author SHA1 Message Date
dad44c0537 Fix Menu Gallery : Gallery Foto
Fix detail berita
2025-12-05 10:56:03 +08:00
867dce42f0 Fix Error Build Staging 2025-12-04 11:58:47 +08:00
7bb17ddf22 Menambahkan menu dokter dan tenaga medis, admin bisa create, edit, delet dokter
Menambahkan menu tarif dan layanan, admin bisa create, edit, delete tarif dan layanan
Dibagian fasilitas kesehatan admin bisa multiselect bagian dokter dan tarif layanan
Di tampilan user juga sudah disesuaikan dengan datanya bisa muncul lebih dari 1 dokter dan 1 tarif layanan
2025-12-03 17:24:03 +08:00
44 changed files with 3483 additions and 1007 deletions

View File

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

View File

@@ -9,29 +9,30 @@ import { z } from "zod";
// Validasi form
const templateForm = z.object({
name: z.string().min(1, "Nama harus diisi"),
informasiUmum: z.object({
fasilitas: z.string().min(1, "Fasilitas harus diisi"),
alamat: z.string().min(1, "Alamat harus diisi"),
jamOperasional: z.string().min(1, "Jam operasional harus diisi"),
fasilitas: z.string().min(1),
alamat: z.string().min(1),
jamOperasional: z.string().min(1),
}),
layananUnggulan: z.object({
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"),
content: z.string().min(1),
}),
// NOW ARRAY OF STRING (ID)
dokterdanTenagaMedis: z.array(z.string()).min(1, "Minimal pilih 1 dokter"),
fasilitasPendukung: z.object({
content: z.string().min(1, "Fasilitas pendukung harus diisi"),
content: z.string().min(1),
}),
prosedurPendaftaran: z.object({
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"),
content: z.string().min(1),
}),
// NOW ARRAY OF STRING (ID)
tarifDanLayanan: z.array(z.string()).min(1, "Minimal pilih 1 tarif"),
});
// Default form kosong
@@ -45,21 +46,34 @@ const defaultForm = {
layananUnggulan: {
content: "",
},
dokterdanTenagaMedis: {
name: "",
specialist: "",
jadwal: "",
},
dokterdanTenagaMedis: [] as string[], // ← array kosong
tarifDanLayanan: [] as string[], // ← array kosong
fasilitasPendukung: {
content: "",
},
prosedurPendaftaran: {
content: "",
},
tarifDanLayanan: {
layanan: "",
tarif: "",
},
};
type DokterItem = {
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({
@@ -186,33 +200,26 @@ const fasilitasKesehatan = proxy({
const result = await res.json();
const data = result.data;
fasilitasKesehatan.edit.id = data.id;
fasilitasKesehatan.edit.form = {
this.id = data.id;
this.form = {
name: data.name,
informasiUmum: {
fasilitas: data.informasiumum.fasilitas,
alamat: data.informasiumum.alamat,
jamOperasional: data.informasiumum.jamOperasional,
},
layananUnggulan: {
content: data.layananunggulan.content,
},
dokterdanTenagaMedis: {
name: data.dokterdantenagamedis.name,
specialist: data.dokterdantenagamedis.specialist,
jadwal: data.dokterdantenagamedis.jadwal,
},
fasilitasPendukung: {
content: data.fasilitaspendukung.content,
},
prosedurPendaftaran: {
content: data.prosedurpendaftaran.content,
},
tarifDanLayanan: {
layanan: data.tarifdanlayanan.layanan,
tarif: data.tarifdanlayanan.tarif,
// map relasi -> array of IDs
layananUnggulan: {
content: data.layananunggulan.content,
},
dokterdanTenagaMedis: data.dokterdantenagamedis?.map((v: DokterItem) => v.id) ?? [],
tarifDanLayanan: data.tarifdanlayanan?.map((v: TarifItem) => v.id) ?? [],
};
},
async submit() {
@@ -238,22 +245,15 @@ const fasilitasKesehatan = proxy({
layananUnggulan: {
content: fasilitasKesehatan.edit.form.layananUnggulan.content,
},
dokterdanTenagaMedis: {
name: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.name,
specialist:
fasilitasKesehatan.edit.form.dokterdanTenagaMedis.specialist,
jadwal: fasilitasKesehatan.edit.form.dokterdanTenagaMedis.jadwal,
},
dokterdanTenagaMedis:
fasilitasKesehatan.edit.form.dokterdanTenagaMedis,
fasilitasPendukung: {
content: fasilitasKesehatan.edit.form.fasilitasPendukung.content,
},
prosedurPendaftaran: {
content: fasilitasKesehatan.edit.form.prosedurPendaftaran.content,
},
tarifDanLayanan: {
layanan: fasilitasKesehatan.edit.form.tarifDanLayanan.layanan,
tarif: fasilitasKesehatan.edit.form.tarifDanLayanan.tarif,
},
tarifDanLayanan: fasilitasKesehatan.edit.form.tarifDanLayanan,
};
const res = await fetch(
@@ -320,12 +320,26 @@ const templateDokterForm = z.object({
name: z.string().min(1, "Nama tidak boleh kosong"),
specialist: z.string().min(1, "Spesialis 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 = {
name: "",
specialist: "",
jadwal: "",
jadwalLibur: "",
jamBukaOperasional: "",
jamTutupOperasional: "",
jamBukaLibur: "",
jamTutupLibur: "",
};
const dokter = proxy({
@@ -463,6 +477,11 @@ const dokter = proxy({
name: data.name,
specialist: data.specialist,
jadwal: data.jadwal,
jadwalLibur: data.jadwalLibur,
jamBukaOperasional: data.jamBukaOperasional,
jamTutupOperasional: data.jamTutupOperasional,
jamBukaLibur: data.jamBukaLibur,
jamTutupLibur: data.jamTutupLibur,
};
return data; // Return the loaded data
} else {
@@ -487,6 +506,11 @@ const dokter = proxy({
name: this.form.name,
specialist: this.form.specialist,
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);
@@ -567,9 +591,255 @@ 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({
fasilitasKesehatan,
dokter,
tarif
});
export default fasilitasKesehatanState;

View File

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

View File

@@ -0,0 +1,303 @@
/* eslint-disable react-hooks/exhaustive-deps */
"use client";
import EditEditor from "@/app/admin/(dashboard)/_com/editEditor";
import stateGallery from "@/app/admin/(dashboard)/_state/desa/gallery";
import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch";
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
TextInput,
Title
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import {
IconArrowBack,
IconPhoto,
IconUpload,
IconX,
} 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 EditFoto() {
const FotoState = useProxy(stateGallery.foto);
const router = useRouter();
const params = useParams();
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [formData, setFormData] = useState({
name: "",
deskripsi: "",
imagesId: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
imagesId: "",
imageUrl: "",
});
// Load kategori + Foto
useEffect(() => {
FotoState.findMany.load();
const loadFoto = async () => {
const id = params?.id as string;
if (!id) return;
try {
const data = await FotoState.update.load(id);
if (data) {
setFormData({
name: data.name || "",
deskripsi: data.deskripsi || "",
imagesId: data.imagesId || "",
});
setOriginalData({
name: data.name || "",
deskripsi: data.deskripsi || "",
imagesId: data.imagesId || "",
imageUrl: data.imageGalleryFoto?.link || ""
});
if (data?.imageGalleryFoto?.link) {
setPreviewImage(data.imageGalleryFoto.link);
}
}
} catch (error) {
console.error("Error loading Foto:", error);
toast.error("Gagal memuat data Foto");
}
};
loadFoto();
}, [params?.id]);
const handleChange = (field: string, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state hanya sekali di sini
FotoState.update.form = {
...FotoState.update.form,
...formData,
};
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal upload gambar");
}
FotoState.update.form.imagesId = uploaded.id;
}
await FotoState.update.update();
toast.success("Foto berhasil diperbarui!");
router.push("/admin/desa/gallery/foto");
} catch (error) {
console.error("Error updating foto:", error);
toast.error("Terjadi kesalahan saat memperbarui foto");
} finally {
setIsSubmitting(false);
}
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
imagesId: originalData.imagesId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
return (
<Box px={{ base: "sm", md: "lg" }} py="md">
{/* Header */}
<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 Foto
</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="Judul Foto"
placeholder="Masukkan judul foto"
value={formData.name}
onChange={(e) => handleChange("name", e.target.value)}
required
/>
{/* Upload Gambar */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Foto
</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/*": [] }}
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>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
objectFit: "contain",
border: `1px solid ${colors["blue-button"]}`,
}}
loading="lazy"
/>
<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>
{/* Deskripsi */}
<Box>
<Text fz="sm" fw="bold">
Deskripsi Foto
</Text>
<EditEditor
value={formData.deskripsi}
onChange={(htmlContent) =>
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
}
/>
</Box>
{/* Action */}
<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 EditFoto;

View File

@@ -0,0 +1,175 @@
'use client';
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Alert } from '@mantine/core';
import Image from 'next/image';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconEdit, IconTrash, IconPhoto } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
import colors from '@/con/colors';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
function DetailFoto() {
const FotoState = useProxy(stateGallery.foto);
const [modalHapus, setModalHapus] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [imageError, setImageError] = useState(false);
const params = useParams();
const router = useRouter();
useShallowEffect(() => {
FotoState.findUnique.load(params?.id as string);
}, []);
const handleHapus = () => {
if (selectedId) {
FotoState.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/desa/gallery/foto");
}
};
if (!FotoState.findUnique.data) {
return (
<Stack py={10}>
<Skeleton height={500} radius="md" />
</Stack>
);
}
const data = FotoState.findUnique.data;
const imageUrl = data.imageGalleryFoto?.link;
return (
<Box py={10}>
<Button
variant="subtle"
onClick={() => router.back()}
leftSection={<IconArrowBack size={24} color={colors['blue-button']} />}
mb={15}
>
Kembali
</Button>
<Paper
withBorder
// Gunakan max-width agar tidak terlalu lebar di desktop
maw={800}
w="100%"
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
>
<Stack gap="md">
<Text fz={{ base: 'xl', md: '2xl' }} fw="bold" c={colors['blue-button']}>
Detail Foto
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
<Stack gap="sm">
<Box>
<Text fz="lg" fw="bold">Judul Foto</Text>
<Text fz="md" c="dimmed">{data.name || '-'}</Text>
</Box>
<Box>
<Text fz="lg" fw="bold">Deskripsi</Text>
<Text
fz="md"
c="dimmed"
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
/>
</Box>
<Box>
<Text fz="lg" fw="bold">Gambar</Text>
{imageUrl ? (
<Box
pos="relative"
style={{
width: '100%',
maxWidth: '600px', // Set a maximum width
margin: '0 auto', // Center the container
aspectRatio: '16/9', // Use 16:9 aspect ratio
borderRadius: 8,
overflow: 'hidden',
position: 'relative'
}}
>
<Image
src={imageUrl}
alt={data.name || 'Gambar Foto'}
fill
style={{
objectFit: 'contain', // Changed from 'cover' to 'contain' to show full image
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}}
loading="lazy"
onError={() => setImageError(true)}
/>
</Box>
) : imageError ? (
<Alert
color="orange"
icon={<IconPhoto size={16} />}
title="Gagal memuat gambar"
radius="md"
>
Gambar tidak dapat ditampilkan.
</Alert>
) : (
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
)}
</Box>
{/* Action Buttons */}
<Group gap="sm" justify="flex-start">
<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/desa/gallery/foto/${data.id}/edit`)}
variant="light"
radius="md"
size="md"
>
<IconEdit size={20} />
</Button>
</Group>
</Stack>
</Paper>
</Stack>
</Paper>
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleHapus}
text="Apakah Anda yakin ingin menghapus foto ini?"
/>
</Box>
);
}
export default DetailFoto;

View File

@@ -0,0 +1,228 @@
'use client';
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
Paper,
Stack,
Text,
TextInput,
Title,
Loader,
Image
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateFoto() {
const FotoState = useProxy(stateGallery.foto);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const resetForm = () => {
FotoState.create.form = {
name: '',
deskripsi: '',
imagesId: '',
};
setPreviewImage(null);
setFile(null);
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
FotoState.create.form.imagesId = uploaded.id;
await FotoState.create.create();
resetForm();
router.push('/admin/desa/gallery/foto');
} catch (error) {
console.error('Error creating foto:', error);
toast.error('Terjadi kesalahan saat membuat foto');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header 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">
Tambah Foto
</Title>
</Group>
{/* Card 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">
{/* Judul */}
<TextInput
label="Judul Foto"
placeholder="Masukkan judul Foto"
value={FotoState.create.form.name}
onChange={(e) => {
FotoState.create.form.name = e.currentTarget.value;
}}
required
/>
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Berita
</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="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
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>
{/* Deskripsi */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Deskripsi Foto
</Text>
<CreateEditor
value={FotoState.create.form.deskripsi}
onChange={(val) => {
FotoState.create.form.deskripsi = val;
}}
/>
</Box>
{/* Button Submit */}
<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 CreateFoto;

View File

@@ -1,157 +1,163 @@
"use client";
import stateFileStorage from "@/state/state-list-image";
'use client'
import colors from '@/con/colors';
import {
ActionIcon,
Box,
Card,
Flex,
Button,
Center,
Group,
Image,
Pagination,
Paper,
SimpleGrid,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
TextInput,
Title
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconSearch, IconTrash, IconX } from "@tabler/icons-react";
import { motion } from "framer-motion";
import toast from "react-simple-toasts";
import { useSnapshot } from "valtio";
export default function ListImage() {
const { list, total } = useSnapshot(stateFileStorage);
useShallowEffect(() => {
stateFileStorage.load();
}, []);
let timeOut: NodeJS.Timer;
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import stateGallery from '../../../_state/desa/gallery';
function Foto() {
const [search, setSearch] = useState("");
return (
<Stack p="lg" gap="lg">
<Flex justify="space-between" align="center" wrap="wrap" gap="md">
<Title order={2} fw={700}>
Galeri Foto
</Title>
<TextInput
radius="xl"
size="md"
placeholder="Cari foto berdasarkan nama..."
leftSection={<IconSearch size={18} />}
rightSection={
<ActionIcon
variant="light"
color="gray"
radius="xl"
onClick={() => stateFileStorage.load()}
>
<IconX size={18} />
</ActionIcon>
}
onChange={(e) => {
if (timeOut) clearTimeout(timeOut);
timeOut = setTimeout(() => {
stateFileStorage.load({ search: e.target.value });
}, 300);
}}
/>
</Flex>
<Paper withBorder radius="lg" p="md" shadow="sm">
{list && list.length > 0 ? (
<SimpleGrid
cols={{ base: 2, sm: 3, md: 5, lg: 8 }}
spacing="md"
verticalSpacing="md"
>
{list.map((v, k) => (
<Card
key={k}
withBorder
radius="md"
shadow="sm"
className="hover:shadow-md transition-all duration-200"
>
<Stack gap="xs">
<motion.div
onClick={() => {
navigator.clipboard.writeText(v.url);
toast("Tautan foto berhasil disalin");
}}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{ cursor: "pointer" }}
>
<Image
src={`${v.url}?size=200`}
alt={v.name}
radius="md"
h={120}
fit="cover"
loading="lazy"
/>
</motion.div>
<Box>
<Text size="sm" fw={500} lineClamp={2}>
{v.name}
</Text>
</Box>
<Group justify="space-between" align="center" pt="xs">
<ActionIcon
variant="subtle"
color="red"
radius="md"
onClick={() => {
stateFileStorage
.del({ id: v.id })
.finally(() => toast("Foto berhasil dihapus"));
}}
>
<IconTrash size={18} />
</ActionIcon>
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
) : (
<Stack align="center" justify="center" py="xl" gap="sm">
<Image
src="https://cdn-icons-png.flaticon.com/512/4076/4076549.png"
alt="Kosong"
w={120}
h={120}
fit="contain"
opacity={0.7}
loading="lazy"
/>
<Text c="dimmed" ta="center">
Belum ada foto yang tersedia
</Text>
</Stack>
)}
</Paper>
{total && total > 1 && (
<Flex justify="center">
<Pagination
total={total}
value={stateFileStorage.page} // Changed from page to value
size="md"
radius="md"
withEdges
onChange={(page) => {
stateFileStorage.load({ page });
}}
/>
</Flex>
)}
</Stack>
<Box>
<HeaderSearch
title='Foto'
placeholder='Cari judul atau deskripsi foto...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<ListFoto search={search} />
</Box>
);
}
function ListFoto({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto)
const router = useRouter();
const {
data,
page,
totalPages,
loading,
load,
} = FotoState.findMany;
useShallowEffect(() => {
load(page, 10, search)
}, [page, search])
const filteredData = data || []
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
)
}
return (
<Box py={10}>
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Foto</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/gallery/foto/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: "auto" }}>
<Table highlightOnHover>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Judul Foto</TableTh>
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh style={{ width: '30%' }}>Deskripsi</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item) => (
<TableTr key={item.id}>
<TableTd style={{ width: '25%' }}>
<Box w={200}>
<Text fw={500} truncate="end" lineClamp={1}>{item.name}</Text>
</Box>
</TableTd>
<TableTd style={{ width: '20%' }}>
<Box w={200}>
<Text fz="sm" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Box>
</TableTd>
<TableTd style={{ width: '30%' }}>
<Box w={200}>
<Text fz="sm" truncate="end" lineClamp={1} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
</Box>
</TableTd>
<TableTd style={{ width: '15%' }}>
<Button
variant="light"
color="blue"
onClick={() => router.push(`/admin/desa/gallery/foto/${item.id}`)}
>
<IconDeviceImac size={20} />
<Text ml={5}>Detail</Text>
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={4}>
<Center py={20}>
<Text c="dimmed">Tidak ada foto yang cocok</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
</Paper>
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10)
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
</Box>
);
}
export default Foto;

View File

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

View File

@@ -9,6 +9,12 @@ import {
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
@@ -108,21 +114,79 @@ function DetailFasilitasKesehatan() {
</Box>
<Box>
<Text fz="lg" fw="bold">Dokter & Tenaga Medis</Text>
<Text fz="md" fw="bold">Nama</Text>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.name || '-'}</Text>
<Text fz="md" fw="bold">Spesialis</Text>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.specialist || '-'}</Text>
<Text fz="md" fw="bold">Jadwal</Text>
<Text fz="md" c="dimmed">{data.dokterdantenagamedis?.jadwal || '-'}</Text>
<Text fz="lg" fw="bold" mb="sm">Dokter & Tenaga Medis</Text>
{Array.isArray(data.dokterdantenagamedis) && data.dokterdantenagamedis.length > 0 ? (
<Box style={{ overflowX: 'auto', width: '100%' }}>
<Table striped highlightOnHover withTableBorder>
<TableThead>
<TableTr>
<TableTh style={{ whiteSpace: 'nowrap' }}>Nama</TableTh>
<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>
<Text fz="lg" fw="bold">Tarif & Layanan</Text>
<Text fz="md" fw="bold">Layanan</Text>
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.layanan || '-'}</Text>
<Text fz="md" fw="bold">Tarif</Text>
<Text fz="md" c="dimmed">{data.tarifdanlayanan?.tarif || '-'}</Text>
<Box mt="xl">
<Text fz="lg" fw="bold" mb="sm">Tarif & Layanan</Text>
{Array.isArray(data.tarifdanlayanan) && data.tarifdanlayanan.length > 0 ? (
<Box style={{ overflowX: 'auto', width: '100%' }}>
<Table striped highlightOnHover withTableBorder>
<TableThead>
<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>
{/* Aksi */}

View File

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

View File

@@ -1,11 +1,241 @@
import React from 'react';
/* eslint-disable @typescript-eslint/no-explicit-any */
/* 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 (
<div>
Page
</div>
<Box px={{ base: 'sm', md: 'lg' }} py="md">
{/* Header */}
<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 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 Page;
export default EditDokterTenagaMedis;

View File

@@ -1,11 +1,165 @@
import React from 'react';
'use client'
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 (
<div>
Page
</div>
<Box py={10}>
{/* Tombol Back */}
<Button
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 Page;
export default DetailDokterTenagaMedis;

View File

@@ -1,71 +1,184 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import fasilitasKesehatanState from '@/app/admin/(dashboard)/_state/kesehatan/data_kesehatan_warga/fasilitasKesehatan';
import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
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 { useRouter } from 'next/navigation';
import { useRef, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateDokter() {
const params = useParams()
const createState = useProxy(fasilitasKesehatanState.dokter)
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
createState.create.create.form = {
name: "",
specialist: "",
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 () => {
await createState.create.create.create();
resetForm();
router.push(`/admin/kesehatan/fasilitas-kesehatan/${params?.id}/dokter-tenaga-medis`)
try {
setIsSubmitting(true);
await createState.create.create.create();
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 (
<Box component="form" onSubmit={handleSubmit}>
<Box mb={10}>
<Button variant="subtle" onClick={() => router.back()}>
<IconArrowBack color={colors['blue-button']} size={25} />
<Box px={{ base: 'sm', md: 'lg' }} py="md" component="form" onSubmit={handleSubmit}>
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
</Box>
<Title order={4} ml="sm" c="dark">
Tambah Data Dokter & Tenaga Medis
</Title>
</Group>
<Paper bg={colors['white-1']} p="md" w={{ base: '100%', md: '50%' }}>
<Stack gap="xs">
<Title order={3}>Create Dokter</Title>
{/* 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={<Text fz="sm" fw="bold">Nama Dokter</Text>}
placeholder="masukkan nama dokter"
label={"Nama Dokter"}
placeholder="Masukkan nama dokter"
value={createState.create.create.form.name}
onChange={(e) => {
createState.create.create.form.name = e.target.value;
}}
onChange={(e) => (createState.create.create.form.name = e.target.value)}
required
/>
<Text fz="md" fw="bold">Specialist</Text>
{/* Informasi Umum */}
<TextInput
label={<Text fz="sm" fw="bold">Specialist</Text>}
placeholder="masukkan specialist"
label="Specialist"
placeholder="Masukkan specialist"
value={createState.create.create.form.specialist}
onChange={(e) => {
createState.create.create.form.specialist = e.target.value;
}}
onChange={(e) => (createState.create.create.form.specialist = e.target.value)}
required
/>
<Box>
<Text fz="md" fw="bold">Jadwal</Text>
<CreateEditor
value={createState.create.create.form.jadwal}
onChange={(htmlContent) => {
createState.create.create.form.jadwal = htmlContent;
<TextInput
label="Jadwal"
placeholder="Masukkan jadwal"
value={createState.create.create.form.jadwal}
onChange={(e) => (createState.create.create.form.jadwal = e.target.value)}
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)',
}}
/>
</Box>
<Button onClick={handleSubmit} bg={colors['blue-button']}>
Simpan
</Button>
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>

View File

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

View File

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

View File

@@ -0,0 +1,173 @@
/* 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

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

View File

@@ -1,18 +1,16 @@
import prisma from "@/lib/prisma";
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 body = await context.body as FasilitasKesehatanInput;
const body = (await context.body) as {
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 {
name,
@@ -24,25 +22,30 @@ const fasilitasKesehatanCreate = async (context: Context) => {
tarifDanLayanan,
} = body;
// Buat masing-masing relasi terlebih dahulu
const [createdInformasiUmum, createdLayananUnggulan, createdDokter, createdPendukung, createdProsedur, createdTarif] = await Promise.all([
prisma.informasiUmum.create({ data: informasiUmum }),
prisma.layananUnggulan.create({ data: layananUnggulan }),
prisma.dokterdanTenagaMedis.create({ data: dokterdanTenagaMedis }),
prisma.fasilitasPendukung.create({ data: fasilitasPendukung }),
prisma.prosedurPendaftaran.create({ data: prosedurPendaftaran }),
prisma.tarifDanLayanan.create({ data: tarifDanLayanan }),
]);
// CREATE SINGLE DATA
const [createdInformasi, createdUnggulan, createdPendukung, createdProsedur] =
await Promise.all([
prisma.informasiUmum.create({ data: informasiUmum }),
prisma.layananUnggulan.create({ data: layananUnggulan }),
prisma.fasilitasPendukung.create({ data: fasilitasPendukung }),
prisma.prosedurPendaftaran.create({ data: prosedurPendaftaran }),
]);
// ✅ CUKUP CONNECT KE ID YANG SUDAH ADA
const fasilitas = await prisma.fasilitasKesehatan.create({
data: {
name,
informasiUmumId: createdInformasiUmum.id,
layananUnggulanId: createdLayananUnggulan.id,
dokterdanTenagaMedisId: createdDokter.id,
informasiUmumId: createdInformasi.id,
layananUnggulanId: createdUnggulan.id,
fasilitasPendukungId: createdPendukung.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: {
informasiumum: true,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
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

@@ -0,0 +1,37 @@
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

@@ -0,0 +1,54 @@
/* 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

@@ -0,0 +1,47 @@
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

@@ -0,0 +1,30 @@
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

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

View File

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

View File

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

View File

@@ -49,7 +49,7 @@ function Page() {
return (
<Stack pos={"relative"} bg={colors.Bg} py={"xl"} gap={"22"} px={{ base: "md", md: 0 }}>
<Stack pos={"relative"} bg={colors.Bg} pb={"xl"} gap={"xs"} px={{ base: "md", md: 0 }}>
<Group px={{ base: "md", md: 100 }}>
<NewsReader />
</Group>

View File

@@ -1,12 +1,44 @@
// app/desa/berita/BeritaLayoutClient.tsx
'use client'
import dynamic from 'next/dynamic';
// app/darmasaba/(pages)/desa/berita/layout.tsx
'use client';
import { usePathname } from 'next/navigation';
import { ReactNode } from 'react';
import dynamic from 'next/dynamic';
import { Box } from '@mantine/core';
import BackButton from '../layanan/_com/BackButto';
import colors from '@/con/colors';
const LayoutTabsBerita = dynamic(
() => import('./_lib/layoutTabs'),
{ ssr: false }
);
export default function BeritaLayoutClient({ children }: { children: React.ReactNode }) {
export default function BeritaLayoutClient({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
if (isDetailPage) {
// Tampilkan tanpa tab menu
return (
<Box bg={colors.Bg}>
<Box pt={33} px={{ base: 'md', md: 100 }}>
<BackButton />
</Box>
{children}
</Box>
);
}
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
return <LayoutTabsBerita>{children}</LayoutTabsBerita>;
}

View File

@@ -1,150 +0,0 @@
'use client';
import colors from '@/con/colors';
import { Box, Center, Image, Pagination, Paper, SimpleGrid, Stack, Text } from '@mantine/core';
import { useCallback, useEffect, useState } from 'react';
import ApiFetch from '@/lib/api-fetch';
interface FileItem {
id: string;
name: string;
link: string;
realName: string;
createdAt: string | Date;
category: string;
path: string;
mimeType: string;
}
export default function FotoContent() {
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const limit = 9; // ✅ ambil 12 data per page
const loadData = useCallback(async (pageNum: number, searchTerm: string) => {
setLoading(true);
try {
const query: Record<string, string> = {
category: 'image',
page: pageNum.toString(),
limit: limit.toString(),
};
if (searchTerm) query.search = searchTerm;
const response = await ApiFetch.api.fileStorage.findMany.get({ query });
if (response.status === 200 && response.data) {
setFiles(response.data.data || []);
setTotalPages(response.data.meta?.totalPages || 1);
} else {
setFiles([]);
}
} catch (err) {
console.error('Load error:', err);
setFiles([]);
} finally {
setLoading(false);
}
}, []);
// ✅ Initial load + update when URL/search changes
useEffect(() => {
const handleRouteChange = () => {
const urlParams = new URLSearchParams(window.location.search);
const urlSearch = urlParams.get('search') || '';
const urlPage = parseInt(urlParams.get('page') || '1');
setSearch(urlSearch);
setPage(urlPage);
loadData(urlPage, urlSearch);
};
const handleSearchUpdate = (e: Event) => {
const { search } = (e as CustomEvent).detail;
setSearch(search);
setPage(1);
loadData(1, search);
};
handleRouteChange();
window.addEventListener('popstate', handleRouteChange);
window.addEventListener('searchUpdate', handleSearchUpdate as EventListener);
return () => {
window.removeEventListener('popstate', handleRouteChange);
window.removeEventListener('searchUpdate', handleSearchUpdate as EventListener);
};
}, [loadData]);
// ✅ Update when page/search changes
useEffect(() => {
loadData(page, search);
}, [page, search, loadData]);
const updateURL = (newSearch: string, newPage: number) => {
const url = new URL(window.location.href);
if (newSearch) url.searchParams.set('search', newSearch);
else url.searchParams.delete('search');
if (newPage > 1) url.searchParams.set('page', newPage.toString());
else url.searchParams.delete('page');
window.history.pushState({}, '', url);
};
const handlePageChange = (newPage: number) => {
setPage(newPage);
updateURL(search, newPage);
};
if (loading && files.length === 0) {
return <Center>Memuat data...</Center>;
}
if (files.length === 0) {
return <Center>Tidak ada foto ditemukan</Center>;
}
return (
<Box pt={20} px={{ base: 'md', md: 100 }}>
<SimpleGrid cols={{ base: 1, md: 3 }}>
{files.map((file) => (
<Paper
key={file.id}
mb={50}
p="md"
radius={26}
bg={colors['white-trans-1']}
style={{ height: '100%' }}
>
<Box style={{ height: '250px', overflow: 'hidden', borderRadius: '12px' }}>
<Image
src={file.link}
alt={file.realName || file.name}
height={250}
width="100%"
style={{ objectFit: 'cover', height: '100%', width: '100%' }}
loading="lazy"
/>
</Box>
<Stack gap="sm" py={10}>
<Text fw="bold" fz={{ base: 'h4', md: 'h3' }}>
{file.realName || file.name}
</Text>
<Text fz="sm" c="dimmed">
{new Date(file.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
})}
</Text>
</Stack>
</Paper>
))}
</SimpleGrid>
<Center mt="xl">
<Pagination total={totalPages} value={page} onChange={handlePageChange} />
</Center>
</Box>
);
}

View File

@@ -1,25 +1,168 @@
'use client'
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import dynamic from 'next/dynamic';
import { Suspense } from 'react';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import {
Box,
Center,
Grid,
Image,
Pagination,
Paper,
Skeleton,
Stack,
Text,
Title,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconPhoto } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
// ✅ Load komponen tanpa SSR
const FotoContent = dynamic(
() => import('./Content'),
{
ssr: false,
loading: () => <div>Memuat konten...</div>
}
);
// Komponen kartu foto
function FotoCard({ item }: { item: any }) {
const router = useRouter();
const handleClick = () => {
router.push(`/darmasaba/galeri/foto/${item.id}`);
};
function PageContent() {
return (
<Suspense fallback={<div>Memuat...</div>}>
<FotoContent />
</Suspense>
<Grid.Col span={{ base: 12, xs: 6, md: 4 }}>
<Paper
shadow="sm"
radius="md"
p={0}
onClick={handleClick}
style={{ cursor: 'pointer', transition: 'transform 0.2s' }}
onMouseEnter={(e) => (e.currentTarget.style.transform = 'scale(1.02)')}
onMouseLeave={(e) => (e.currentTarget.style.transform = 'scale(1)')}
>
{item.imageGalleryFoto?.link ? (
<Box
pos="relative"
style={{
paddingBottom: '100%', // ✅ Ubah ke 1:1 (square) — atau sesuaikan
overflow: 'hidden',
borderRadius: '4px 4px 0 0',
backgroundColor: '#f9f9f9', // ✅ background netral
}}
>
<Image
radius="lg"
src={item.imageGalleryFoto.link}
alt={item.name || 'Foto Galeri'}
p={10}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
objectFit: 'contain', // ✅ Tampilkan utuh, jangan crop
objectPosition: 'center', // rata tengah
}}
loading="lazy"
/>
</Box>
) : (
<Center h={180} bg="gray.1">
<IconPhoto size={40} color="gray" />
</Center>
)}
<Stack p="md" gap={4}>
<Text fw={600} lineClamp={1}>
{item.name || 'Tanpa Judul'}
</Text>
{item.deskripsi && (
<Text fz="sm" c="dimmed" lineClamp={2} dangerouslySetInnerHTML={{ __html: item.deskripsi }} />
)}
<Text fz="xs" c="dimmed">
{new Date(item.createdAt).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'short',
year: 'numeric',
})}
</Text>
</Stack>
</Paper>
</Grid.Col>
);
}
export default function Page() {
return <PageContent />;
// Komponen utama
export default function GaleriFotoUser() {
const [search] = useState('');
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
{/* Header */}
<Title order={2} c={colors['blue-button']} mb="lg">
Galeri Foto Desa Darmasaba
</Title>
{/* Daftar Foto */}
<FotoList search={search} />
</Box>
);
}
function FotoList({ search }: { search: string }) {
const FotoState = useProxy(stateGallery.foto);
const { data, page, totalPages, loading, load } = FotoState.findMany;
useShallowEffect(() => {
load(page, 3, search); // ✅ 9 item per halaman
}, [page, search]);
if (loading) {
return (
<Grid mt="md">
{Array.from({ length: 3 }).map((_, i) => (
<Grid.Col key={i} span={{ base: 12, xs: 6, md: 4 }}>
<Skeleton height={280} radius="md" />
</Grid.Col>
))}
</Grid>
);
}
if (!data || data.length === 0) {
return (
<Center py="xl">
<Stack align="center" c="dimmed">
<IconPhoto size={48} />
<Text>Tidak ada foto ditemukan</Text>
</Stack>
</Center>
);
}
return (
<Stack mt="md" gap="xl">
<Grid>
{data.map((item) => (
<FotoCard key={item.id} item={item} />
))}
</Grid>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 3, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
color="blue"
radius="md"
/>
</Center>
</Stack>
);
}

View File

@@ -1,9 +1,36 @@
'use client'
import { usePathname } from "next/navigation";
import { ReactNode } from "react";
import LayoutTabsGalery from "./_lib/layoutTabs";
export default function LayoutGalery({ children }: { children: React.ReactNode }) {
return (
<LayoutTabsGalery>
{children}
</LayoutTabsGalery>
)
// export default function LayoutGalery({ children }: { children: React.ReactNode }) {
// return (
// <LayoutTabsGalery>
// {children}
// </LayoutTabsGalery>
// )
// }
export default function BeritaLayoutClient({
children,
}: {
children: ReactNode;
}) {
const pathname = usePathname();
// Contoh path:
// - /darmasaba/desa/berita/semua → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan → panjang 5 → list
// - /darmasaba/desa/berita/Pemerintahan/123 → panjang 6 → detail
const segments = pathname.split('/').filter(Boolean);
const isDetailPage = segments.length === 5; // [darmasaba, desa, berita, kategori, id]
if (isDetailPage) {
// Tampilkan tanpa tab menu
return <>{children}</>
}
// Tampilkan dengan tab menu (untuk /semua atau /kategori)
return <LayoutTabsGalery>{children}</LayoutTabsGalery>;
}

View File

@@ -4,26 +4,27 @@ import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
SimpleGrid,
Spoiler,
Stack,
Text,
Text
} from '@mantine/core';
import { useCallback, useEffect, useState } from 'react';
import { useTransitionRouter } from 'next-view-transitions';
import { useCallback, useEffect } from 'react';
import { useSnapshot } from 'valtio';
export default function VideoContent() {
// ✅ expanded state per index
const [expandedMap, setExpandedMap] = useState<Record<number, boolean>>({});
const videoState = useSnapshot(stateGallery.video);
const router = useTransitionRouter()
const { data, page, totalPages, loading } = videoState.findMany;
// Handle search and pagination changes
const loadData = useCallback((pageNum: number, searchTerm: string) => {
stateGallery.video.findMany.load(pageNum, 10, searchTerm.trim());
stateGallery.video.findMany.load(pageNum, 3, searchTerm.trim());
}, []);
// Initial load and URL change handler
@@ -56,12 +57,6 @@ export default function VideoContent() {
loadData(newPage, search);
};
const toggleExpanded = (index: number, value: boolean) => {
setExpandedMap((prev) => ({
...prev,
[index]: value,
}));
};
const dataVideo = data || [];
@@ -110,27 +105,22 @@ export default function VideoContent() {
<Text fw="bold" fz="sm" lineClamp={1}>
{v.name}
</Text>
<Spoiler
showLabel={
<Text fw="bold" fz="sm" c={colors['blue-button']}>
Show more
</Text>
}
hideLabel={
<Text fw="bold" fz="sm" c={colors['blue-button']}>
Hide details
</Text>
}
expanded={expandedMap[k] || false}
onExpandedChange={(val) => toggleExpanded(k, val)}
<Text
ta="justify"
fz="sm"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
lineClamp={3}
truncate="end"
/>
<Group justify={"right"}>
<Button
onClick={() => router.push(`/darmasaba/desa/galery/video/${v.id}`)}
bg={colors['blue-button']}
>
<Text
ta="justify"
fz="sm"
dangerouslySetInnerHTML={{ __html: v.deskripsi }}
style={{wordBreak: "break-word", whiteSpace: "normal"}}
/>
</Spoiler>
Detail
</Button>
</Group>
</Stack>
</Box>
</Paper>

View File

@@ -0,0 +1,197 @@
'use client';
import colors from '@/con/colors';
import {
Alert,
Box,
Button,
Card,
Group,
Paper,
Skeleton,
Stack,
Text,
ThemeIcon,
} from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconInfoCircle, IconVideo } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery'; // pastikan state bisa dipakai di publik
import BackButton from '../../../layanan/_com/BackButto';
// Fungsi helper: aman dan tanpa spasi
function convertToEmbedUrl(youtubeUrl: string): string {
try {
const url = new URL(youtubeUrl);
let videoId = '';
if (url.hostname === 'youtu.be') {
videoId = url.pathname.slice(1);
} else if (url.hostname.includes('youtube.com')) {
videoId = url.searchParams.get('v') || '';
}
return videoId ? `https://www.youtube.com/embed/${videoId}` : youtubeUrl;
} catch {
return youtubeUrl;
}
}
export default function DetailVideoUser() {
const params = useParams<{ id: string }>();
const router = useRouter();
const videoState = useProxy(stateGallery.video);
const [videoError, setVideoError] = useState(false);
const id = Array.isArray(params.id) ? params.id[0] : params.id;
useShallowEffect(() => {
if (id) {
videoState.findUnique.load(id);
}
}, [id]);
const data = videoState.findUnique.data;
if (!videoState.findUnique && !id) {
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
<Skeleton height={400} radius="md" />
</Box>
);
}
if (!data) {
return (
<Box py="xl" px={{ base: 'md', md: 'lg' }}>
<Alert
icon={<IconInfoCircle size={20} />}
title="Video tidak ditemukan"
color="red"
radius="md"
>
Video yang Anda cari tidak tersedia.
</Alert>
<Button
leftSection={<IconArrowBack size={16} />}
mt="md"
onClick={() => router.push('/darmasaba/galeri/video')}
variant="outline"
>
Kembali ke Galeri
</Button>
</Box>
);
}
const embedUrl = data.linkVideo ? convertToEmbedUrl(data.linkVideo) : null;
return (
<Box py="xl" px={{ base: 'md', md: 100 }}>
{/* Tombol Kembali */}
<Box >
<BackButton />
</Box>
{/* Header */}
<Text
ta="center"
fz={{ base: 'xl', md: '2xl' }}
fw={700}
c={colors['blue-button']}
mb="lg"
>
{data.name || 'Video Galeri Desa'}
</Text>
{/* Konten Utama */}
<Card
shadow="sm"
radius="md"
p={{ base: 'md', md: 'xl' }}
bg={colors['white-1']}
>
<Stack gap="lg">
{/* Video */}
{embedUrl ? (
<Box
pos="relative"
style={{ paddingBottom: '56.25%', height: 0, overflow: 'hidden' }} // 16:9 aspect ratio
>
<iframe
src={embedUrl}
title={data.name}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
border: 'none',
}}
onError={() => setVideoError(true)}
/>
</Box>
) : videoError ? (
<Alert
color="orange"
icon={<IconVideo size={20} />}
title="Gagal memuat video"
radius="md"
>
Mohon maaf, video tidak dapat diputar.
</Alert>
) : (
<Alert
color="gray"
icon={<IconInfoCircle size={20} />}
title="Tidak ada video"
radius="md"
>
Konten video belum tersedia.
</Alert>
)}
{/* Informasi Tambahan */}
{data.createdAt && (
<Group gap="xs" justify="center" wrap="nowrap">
<ThemeIcon variant="light" size="sm" radius="xl">
<IconInfoCircle size={14} />
</ThemeIcon>
<Text fz="sm" c="dimmed">
Diunggah pada{' '}
{new Date(data.createdAt).toLocaleDateString('id-ID', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</Text>
</Group>
)}
{/* Deskripsi */}
{data.deskripsi && (
<Paper p="md" bg="gray.0" radius="md">
<Text
fz="md"
c="dark"
dangerouslySetInnerHTML={{ __html: data.deskripsi }}
style={{
wordBreak: 'break-word',
whiteSpace: 'normal',
lineHeight: 1.6,
}}
/>
</Paper>
)}
</Stack>
</Card>
</Box>
);
}

View File

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

View File

@@ -50,7 +50,9 @@ function SosmedView({
loading="lazy"
src={src}
alt={item.name}
fit={item.image?.link ? "cover" : "contain"}
w={24}
h={24}
fit="contain"
/>
);
}

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