Compare commits

...

34 Commits

Author SHA1 Message Date
a00481152c Fix Konsisten teks di tampilan mobile dan desktop
Fix QC Kak Inno tgl 10 Des
Fix QC Kak Ayu tgl 10 Des
2025-12-11 17:58:03 +08:00
242ea86f77 Fix konsisten font, menu landing page & PPID 2025-12-10 17:44:31 +08:00
99c2c9c6d7 Fix semua tulisan profile jadi profil, mulai dari navbar, dan route 2025-12-10 14:16:15 +08:00
ac2fc1a705 Fix QC Kak Inno 8 Des
Fix QC Kak Ayu 8 Des
Fix QC Pak Jun 8 Des
2025-12-09 17:27:23 +08:00
9dbe172165 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 12:00:27 +08:00
cc318d4d54 Fix QC Kak Inno Tgl 4 & 5 Desember
Fix QC Kak Ayu Tgl 4 & 5 Desember
Fix QC Pak Jun Tgl 5 Desember
2025-12-09 10:28:17 +08:00
dcb8017594 Fix undefined ke detail berita terbaru 2025-12-05 17:42:04 +08:00
ec3ad12531 Fix Notifikasi saat ada berita atau pengumuman baru, notifikasi baru muncul. Ga setiap masuk landing page ada notifikasi 2025-12-05 14:30:53 +08:00
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
a4069d3cba Fix UI Sosial Media Landing Page in User 2025-12-02 16:45:55 +08:00
ffe5e6dd9f Fix menu admin landing page, submenu sosial media 2025-12-02 16:06:14 +08:00
dcf195f54f Tambahan filter data sesuai tahun, di landing page apbdes 2025-12-01 17:11:24 +08:00
c03a6b3aed Tambah Term of Service di Registrasi 2025-12-01 14:01:03 +08:00
1bb9f239db Tambah Term of Service di Registrasi 2025-12-01 13:50:25 +08:00
a213ff7d37 Tambah Term of Service di Registrasi 2025-12-01 12:10:22 +08:00
0018bdc251 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:03:18 +08:00
83fb39a957 Fix Ganti Role, ganti role menunya sudah menyesuaikan 2025-11-28 15:00:09 +08:00
7238692dd0 Push WebDesaDarmasabaSatging 2025-11-28 13:56:40 +08:00
8b50139d79 Push Staging 2025-11-28 12:03:07 +08:00
066180fc0e Fix registrasi, waitong-room, & tampilan layout sesuai id 2025-11-28 11:13:20 +08:00
67f29aabef Balik ke awal 2025-11-27 18:53:33 +08:00
dbf7c34228 Fix eror registrasi 2 2025-11-27 17:08:17 +08:00
036fc86fed Fix eror registrasi 1 2025-11-27 16:45:47 +08:00
2cecec733e Tambah cookies di bagian verifikasi, agar kedeteksi user sudah regis apa belom 2025-11-27 14:46:49 +08:00
c64a2e5457 Fix Seeder User, dan role 2025-11-27 12:18:15 +08:00
757911d7dd Fix Seeder 2025-11-26 15:32:49 +08:00
54232e4465 Menambahkan seed user
Fix Infinite reload di page ikm dan landing page
2025-11-26 15:01:34 +08:00
29a9a59bca saat tampilan user sudah diubah dan login ulan sudah menyesuaikan untuk menunya 2025-11-26 11:01:23 +08:00
2fb3666e57 User yang sudah registrasi sudah langsung diarahkan ke layout sesuai dengan roleIdnya
Superadmin sudah bisa menambah atau mengurangkan menu pad user yang diinginkan
Next-------------------------------
Ada bug saat tampilan menu sudah di edit superamin berhasil namun saat user logout tampilan menunya balik ke sebelumnya
2025-11-26 10:14:05 +08:00
e30b27f7a4 Fix Search 2025-11-25 17:30:41 +08:00
e941ed3893 Sudah fix menunya, superadmin bisa memilihkan menu untuk user 2025-11-25 16:21:15 +08:00
ace5aff1b6 Fix Kondisi Verify Otp Registrasi dan Login
Next mau fix eror saat user sudah terdaftar tetapi di redirect ke login, seharusnya redirect sesuai roleIdnya
2025-11-25 15:03:27 +08:00
227 changed files with 12933 additions and 6307 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -54,6 +54,7 @@
"chart.js": "^4.4.8",
"classnames": "^2.5.1",
"colors": "^1.4.0",
"date-fns": "^4.1.0",
"dayjs": "^1.11.13",
"dotenv": "^17.2.3",
"elysia": "^1.3.5",

View File

@@ -1,24 +1,30 @@
[
{
"id": "0",
"name": "DEVELOPER",
"description": "Developer",
"isActive": true
},
{
"id": "1",
"name": "SUPER ADMIN",
"description": "Administrator",
"isActive": true
},
{
"id": "1",
"id": "2",
"name": "ADMIN DESA",
"description": "Administrator Desa",
"isActive": true
},
{
"id": "2",
"id": "3",
"name": "ADMIN KESEHATAN",
"description": "Administrator Bidang Kesehatan",
"isActive": true
},
{
"id": "3",
"id": "4",
"name": "ADMIN PENDIDIKAN",
"description": "Administrator Bidang Pendidikan",
"isActive": true

View File

@@ -0,0 +1,10 @@
[
{
"id": "cmie1o0zh0002vn132vtzg7hh",
"username": "SuperAdmin-Nico",
"nomor": "6289647037426",
"roleId": 0,
"isActive": true,
"sessionInvalid": false
}
]

View File

@@ -136,6 +136,7 @@ model MediaSosial {
name String
image FileStorage? @relation(fields: [imageId], references: [id])
imageId String?
icon String?
iconUrl String? @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -782,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 {
@@ -825,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 {
@@ -864,7 +868,7 @@ model TarifDanLayanan {
updatedAt DateTime @updatedAt
deletedAt DateTime @default(now())
isActive Boolean @default(true)
FasilitasKesehatan FasilitasKesehatan[]
FasilitasKesehatan FasilitasKesehatan[] @relation("Tarif")
}
// ========================================= JADWAL KEGIATAN ========================================= //
@@ -2164,25 +2168,27 @@ enum StatusPeminjaman {
model User {
id String @id @default(cuid())
username String @unique
username String
nomor String @unique
role Role @relation(fields: [roleId], references: [id])
roleId String @default("1")
instansi String?
UserSession UserSession? // Nama instansi (Puskesmas, Sekolah, dll)
isActive Boolean @default(true)
roleId String @default("2")
isActive Boolean @default(false)
sessionInvalid Boolean @default(false)
lastLogin DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
sessionInvalid Boolean @default(false)
UserMenuAccess UserMenuAccess[]
updatedAt DateTime @default(now()) @updatedAt
permissions Json?
sessions UserSession[] // ✅ Relasi one-to-many
role Role @relation(fields: [roleId], references: [id])
menuAccesses UserMenuAccess[]
@@map("users")
}
model Role {
id String @id @default(cuid())
name String @unique // ADMIN_DESA, ADMIN_KESEHATAN, ADMIN_SEKOLAH
description String?
permissions Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -2202,14 +2208,19 @@ model KodeOtp {
}
model UserSession {
id String @id @default(cuid())
token String
expires DateTime?
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
User User @relation(fields: [userId], references: [id])
userId String @unique
id String @id @default(cuid())
token String @db.Text // ✅ JWT bisa panjang
expiresAt DateTime // ✅ Ubah jadi expiresAt (konsisten)
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String // ✅ HAPUS @unique - user bisa punya multiple sessions
@@index([userId]) // ✅ Index untuk query cepat
@@index([token]) // ✅ Index untuk verify cepat
@@map("user_sessions")
}
model UserMenuAccess {

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unused-vars */
import prisma from "@/lib/prisma";
import profilePejabatDesa from "./data/landing-page/profile/profile.json";
@@ -57,46 +58,107 @@ import roles from "./data/user/roles.json";
import fileStorage from "./data/file-storage.json";
import jenjangPendidikan from "./data/pendidikan/info-sekolah/jenjang-pendidikan.json";
import seedAssets from "./seed_assets";
import users from "./data/user/users.json";
import { safeSeedUnique } from "./safeseedUnique";
(async () => {
// =========== ROLE ===========
// In your seed.ts
// =========== ROLES ===========
console.log("🔄 Seeding roles...");
for (const r of roles) {
await safeSeedUnique("role", { id: r.id }, {
name: r.name,
description: r.description,
isActive: r.isActive,
});
}
console.log("✅ Roles seeded");
for (const r of roles) {
try {
// ✅ Destructure to remove permissions if exists
const { permissions, ...roleData } = r as any;
await safeSeedUnique(
"role",
{ name: roleData.name },
{
id: roleData.id,
name: roleData.name,
description: roleData.description,
permissions: roleData.permissions || {}, // ✅ Include permissions
isActive: roleData.isActive,
}
);
console.log(`✅ Seeded role -> ${roleData.name}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(`⚠️ Role already exists (skipping): ${r.name}`);
} else {
console.error(`❌ Failed to seed role ${r.name}:`, error.message);
}
}
}
console.log("✅ Roles seeding completed");
// =========== USER ===========
console.log("🔄 Seeding users...");
for (const u of users) {
try {
// Verify role exists first
const roleExists = await prisma.role.findUnique({
where: { id: u.roleId.toString() },
select: { id: true }, // Only select id to minimize query
});
if (!roleExists) {
console.error(
`❌ Role with id ${u.roleId} not found for user ${u.username}`
);
continue;
}
await safeSeedUnique(
"user",
{ id: u.id },
{
username: u.username,
nomor: u.nomor,
roleId: u.roleId.toString(),
isActive: u.isActive,
sessionInvalid: false,
}
);
console.log(`✅ Seeded user -> ${u.username}`);
} catch (error: any) {
if (error.code === "P2003") {
console.error(
`❌ Foreign key constraint failed for user ${u.username}: Role ${u.roleId} does not exist`
);
} else {
console.error(`❌ Failed to seed user ${u.username}:`, error.message);
}
}
}
console.log("✅ Users seeding completed");
// =========== FILE STORAGE ===========
console.log("🔄 Seeding file storage...");
for (const f of fileStorage) {
await prisma.fileStorage.upsert({
where: { id: f.id },
update: {
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
create: {
id: f.id,
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
});
try {
await prisma.fileStorage.upsert({
where: { id: f.id },
update: {
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
create: {
id: f.id,
name: f.name,
realName: f.realName,
path: f.path,
mimeType: f.mimeType,
link: f.link,
category: f.category,
},
});
} catch (error: any) {
console.error(`❌ Failed to seed file storage ${f.name}:`, error.message);
}
}
console.log("✅ File storage seeded");
// =========== LANDING PAGE ===========
@@ -515,15 +577,40 @@ import { safeSeedUnique } from "./safeseedUnique";
console.log("posisi organisasi berhasil");
// =========== PEGAWAI PPID ===========
console.log("🔄 Seeding pegawai PPID...");
const flattenedPegawai = pegawaiPPID.flat();
// Check for duplicate emails
const emails = new Set();
for (const p of flattenedPegawai) {
await prisma.pegawaiPPID.upsert({
where: { id: p.id },
update: p,
create: p,
});
if (emails.has(p.email)) {
console.warn(`⚠️ Duplicate email found in pegawaiPPID: ${p.email}`);
}
emails.add(p.email);
}
console.log("pegawai berhasil");
for (const p of flattenedPegawai) {
try {
await prisma.pegawaiPPID.upsert({
where: { id: p.id },
update: p,
create: p,
});
console.log(`✅ Seeded pegawai PPID -> ${p.namaLengkap}`);
} catch (error: any) {
if (error.code === "P2002") {
console.warn(
`⚠️ Pegawai PPID with duplicate email (skipping): ${p.email}`
);
} else {
console.error(
`❌ Failed to seed pegawai PPID ${p.namaLengkap}:`,
error.message
);
}
}
}
console.log("✅ pegawai PPID seeding completed");
// =========== SUBMENU VISI MISI PPID ===========
@@ -787,7 +874,9 @@ import { safeSeedUnique } from "./safeseedUnique";
const flattenedPosisiBumdes = posisiOrganisasi.flat();
// ✅ Urutkan berdasarkan hierarki
const sortedPosisiBumdes = flattenedPosisiBumdes.sort((a, b) => a.hierarki - b.hierarki);
const sortedPosisiBumdes = flattenedPosisiBumdes.sort(
(a, b) => a.hierarki - b.hierarki
);
for (const p of sortedPosisiBumdes) {
console.log(`Seeding: ${p.nama} (id: ${p.id}, parent: ${p.parentId})`);
@@ -867,7 +956,7 @@ import { safeSeedUnique } from "./safeseedUnique";
// Add IDs to the kategoriKegiatan data
const kategoriKegiatan = kategoriKegiatanData.map((k, index) => ({
...k,
id: `kategori-${index + 1}`
id: `kategori-${index + 1}`,
}));
for (const k of kategoriKegiatan) {
@@ -1159,7 +1248,6 @@ import { safeSeedUnique } from "./safeseedUnique";
// seed assets
await seedAssets();
})()
.then(() => prisma.$disconnect())
.catch((e) => {

BIN
public/mangupuraaward.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

View File

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

View File

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

View File

@@ -9,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

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

View File

@@ -6,145 +6,176 @@ import { z } from "zod";
const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
nik: z.string().min(3, "NIK minimal 3 karakter"),
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
nik: z
.string()
.min(3, "NIK minimal 3 karakter")
.max(16, "NIK maksimal 16 angka"),
notelp: z
.string()
.min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"),
alamat: z.string().min(3, "Alamat minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"),
jenisInformasiDimintaId: z.string().nonempty(),
caraMemperolehInformasiId: z.string().nonempty(),
caraMemperolehSalinanInformasiId: z.string().nonempty(),
})
});
const jenisInformasiDiminta = proxy({
findMany: {
data: null as
| null
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
async load(){
const res = await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi["find-many"].get();
if (res.status === 200) {
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
}
}
}
})
findMany: {
data: null as
| null
| Prisma.JenisInformasiDimintaGetPayload<{ omit: { isActive: true } }>[],
async load() {
const res =
await ApiFetch.api.ppid.permohonaninformasipublik.jenisInformasi[
"find-many"
].get();
if (res.status === 200) {
jenisInformasiDiminta.findMany.data = res.data?.data ?? [];
}
},
},
});
const caraMemperolehInformasi = proxy({
findMany: {
data: null as
| null
| Prisma.CaraMemperolehInformasiGetPayload<{ omit: { isActive: true } }>[],
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi["find-many"].get();
if (res.status === 200) {
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
}
}
}
})
findMany: {
data: null as
| null
| Prisma.CaraMemperolehInformasiGetPayload<{
omit: { isActive: true };
}>[],
async load() {
const res =
await ApiFetch.api.ppid.permohonaninformasipublik.memperolehInformasi[
"find-many"
].get();
if (res.status === 200) {
caraMemperolehInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
const caraMemperolehSalinanInformasi = proxy({
findMany: {
data: null as
| null
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{ omit: { isActive: true } }>[],
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi["find-many"].get();
if (res.status === 200) {
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
}
}
}
})
console.log(caraMemperolehSalinanInformasi)
findMany: {
data: null as
| null
| Prisma.CaraMemperolehSalinanInformasiGetPayload<{
omit: { isActive: true };
}>[],
async load() {
const res =
await ApiFetch.api.ppid.permohonaninformasipublik.salinanInformasi[
"find-many"
].get();
if (res.status === 200) {
caraMemperolehSalinanInformasi.findMany.data = res.data?.data ?? [];
}
},
},
});
console.log(caraMemperolehSalinanInformasi);
type PermohonanInformasiPublikForm = Prisma.PermohonanInformasiPublikGetPayload<{
type PermohonanInformasiPublikForm =
Prisma.PermohonanInformasiPublikGetPayload<{
select: {
name: true;
nik: true;
notelp: true;
alamat: true;
email: true;
jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true;
name: true;
nik: true;
notelp: true;
alamat: true;
email: true;
jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true;
};
}>;
}>;
const statepermohonanInformasiPublik = proxy({
create: {
form: {} as PermohonanInformasiPublikForm,
loading: false,
async create(){
const cek = templateForm.safeParse(statepermohonanInformasiPublik.create.form);
if(!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
statepermohonanInformasiPublik.create.loading = true;
const res = await ApiFetch.api.ppid.permohonaninformasipublik["create"].post(statepermohonanInformasiPublik.create.form);
if (res.status === 200) {
statepermohonanInformasiPublik.findMany.load();
return toast.success("Sukses menambahkan");
}
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
} finally {
statepermohonanInformasiPublik.create.loading = false;
}
create: {
form: {} as PermohonanInformasiPublikForm,
loading: false,
async create() {
const cek = templateForm.safeParse(
statepermohonanInformasiPublik.create.form
);
if (!cek.success) {
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
return false; // ⬅️ tambahkan return false
}
try {
statepermohonanInformasiPublik.create.loading = true;
const res = await ApiFetch.api.ppid.permohonaninformasipublik[
"create"
].post(statepermohonanInformasiPublik.create.form);
if (res.data?.success === false) {
toast.error(res.data?.message);
return false; // ⬅️ gagal
}
toast.success("Sukses menambahkan");
return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
statepermohonanInformasiPublik.create.loading = false;
}
},
findMany: {
data: null as
| Prisma.PermohonanInformasiPublikGetPayload<{ include: {
caraMemperolehSalinanInformasi: true,
jenisInformasiDiminta: true,
caraMemperolehInformasi: true,
} }>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik["find-many"].get();
if (res.status === 200) {
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
}
}
},
findUnique: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
},
findMany: {
data: null as
| Prisma.PermohonanInformasiPublikGetPayload<{
include: {
jenisInformasiDiminta: true,
caraMemperolehInformasi: true,
caraMemperolehSalinanInformasi: true,
caraMemperolehSalinanInformasi: true;
jenisInformasiDiminta: true;
caraMemperolehInformasi: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch program inovasi:", res.statusText);
statepermohonanInformasiPublik.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching program inovasi:", error);
statepermohonanInformasiPublik.findUnique.data = null;
}
},
},
})
}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonaninformasipublik[
"find-many"
].get();
if (res.status === 200) {
statepermohonanInformasiPublik.findMany.data = res.data?.data ?? [];
}
},
},
findUnique: {
data: null as Prisma.PermohonanInformasiPublikGetPayload<{
include: {
jenisInformasiDiminta: true;
caraMemperolehInformasi: true;
caraMemperolehSalinanInformasi: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonaninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
statepermohonanInformasiPublik.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch program inovasi:", res.statusText);
statepermohonanInformasiPublik.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching program inovasi:", error);
statepermohonanInformasiPublik.findUnique.data = null;
}
},
},
});
const statepermohonanInformasiPublikForm = proxy({
statepermohonanInformasiPublik,
jenisInformasiDiminta,
caraMemperolehInformasi,
caraMemperolehSalinanInformasi,
})
statepermohonanInformasiPublik,
jenisInformasiDiminta,
caraMemperolehInformasi,
caraMemperolehSalinanInformasi,
});
export default statepermohonanInformasiPublikForm;

View File

@@ -5,82 +5,99 @@ import { proxy } from "valtio";
import { z } from "zod";
const templateForm = z.object({
name: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"),
notelp: z.string().min(3, "Nomor Telepon minimal 3 karakter"),
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
})
name: z.string().min(3, "Nama minimal 3 karakter"),
email: z.string().min(3, "Email minimal 3 karakter"),
notelp: z
.string()
.min(3, "Nomor Telepon minimal 3 karakter")
.max(15, "Nomor Telepon maksimal 15 angka"),
alasan: z.string().min(3, "Alasan minimal 3 karakter"),
});
type PermohonanKeberatanInformasiForm = Prisma.FormulirPermohonanKeberatanGetPayload<{
type PermohonanKeberatanInformasiForm =
Prisma.FormulirPermohonanKeberatanGetPayload<{
select: {
name: true;
email: true;
notelp: true;
alasan: true;
name: true;
email: true;
notelp: true;
alasan: true;
};
}>;
}>;
const permohonanKeberatanInformasi = proxy({
create: {
form: {} as PermohonanKeberatanInformasiForm,
loading: false,
async create(){
const cek = templateForm.safeParse(permohonanKeberatanInformasi.create.form);
if(!cek.success) {
const err = `[${cek.error.issues
.map((v) => `${v.path.join(".")}`)
.join("\n")}] required`;
return toast.error(err);
}
try {
permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["create"].post(permohonanKeberatanInformasi.create.form);
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.load();
return toast.success("Sukses menambahkan");
}
return toast.error("failed create");
} catch (error) {
console.log((error as Error).message);
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.FormulirPermohonanKeberatanGetPayload<{omit: {isActive: true}}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik["find-many"].get();
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
}
}
},
findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: {
isActive: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(`/api/ppid/permohonankeberataninformasipublik/${id}`);
if (res.ok) {
const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch permohonan keberatan informasi:", res.statusText);
permohonanKeberatanInformasi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findUnique.data = null;
}
},
create: {
form: {} as PermohonanKeberatanInformasiForm,
loading: false,
async create() {
const cek = templateForm.safeParse(
permohonanKeberatanInformasi.create.form
);
if (!cek.success) {
toast.error(cek.error.issues.map((i) => i.message).join("\n"));
return false; // ⬅️ tambahkan return false
}
try {
permohonanKeberatanInformasi.create.loading = true;
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"create"
].post(permohonanKeberatanInformasi.create.form);
if (res.data?.success === false) {
toast.error(res.data?.message);
return false; // ⬅️ gagal
}
toast.success("Sukses menambahkan");
return true; // ⬅️ sukses
} catch {
toast.error("Terjadi kesalahan server");
return false;
} finally {
permohonanKeberatanInformasi.create.loading = false;
}
},
},
findMany: {
data: null as
| Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: { isActive: true };
}>[]
| null,
async load() {
const res = await ApiFetch.api.ppid.permohonankeberataninformasipublik[
"find-many"
].get();
if (res.status === 200) {
permohonanKeberatanInformasi.findMany.data = res.data?.data ?? [];
}
},
},
findUnique: {
data: null as Prisma.FormulirPermohonanKeberatanGetPayload<{
omit: {
isActive: true;
};
}> | null,
async load(id: string) {
try {
const res = await fetch(
`/api/ppid/permohonankeberataninformasipublik/${id}`
);
if (res.ok) {
const data = await res.json();
permohonanKeberatanInformasi.findUnique.data = data.data ?? null;
} else {
console.error(
"Failed to fetch permohonan keberatan informasi:",
res.statusText
);
permohonanKeberatanInformasi.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching permohonan keberatan informasi:", error);
permohonanKeberatanInformasi.findUnique.data = null;
}
},
},
});
export default permohonanKeberatanInformasi;

View File

@@ -118,7 +118,7 @@ const userState = proxy({
console.error("Gagal delete user:", error);
toast.error("Terjadi kesalahan saat menghapus user");
} finally {
userState.delete.loading = false;
userState.deleteUser.loading = false;
}
},
},

View File

@@ -15,7 +15,9 @@ function Login() {
// Login.tsx
async function onLogin() {
const cleanPhone = phone.replace(/\D/g, '');
console.log(cleanPhone);
if (cleanPhone.length < 10) {
toast.error('Nomor telepon tidak valid');
return;
@@ -25,6 +27,8 @@ function Login() {
setLoading(true);
const response = await apiFetchLogin({ nomor: cleanPhone });
console.log(response);
if (!response.success) {
toast.error(response.message || 'Gagal memproses login');
return;
@@ -32,11 +36,12 @@ function Login() {
// Simpan nomor untuk register
localStorage.setItem('auth_nomor', cleanPhone);
if (response.isRegistered) {
// ✅ User lama: simpan kodeId & ke validasi
// ✅ User lama: simpan kodeId
localStorage.setItem('auth_kodeId', response.kodeId);
router.push('/validasi');
// ✅ Cookie sudah di-set oleh API, langsung redirect
router.push('/validasi'); // Clean URL
} else {
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
router.push('/registrasi');

View File

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

View File

@@ -1,10 +1,17 @@
//
'use client';
import { apiFetchOtpData, apiFetchVerifyOtp } from '@/app/api/auth/_lib/api_fetch_auth';
import colors from '@/con/colors';
import { Box, Button, Center, Loader, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
import {
Box,
Button,
Center,
Loader,
Paper,
PinInput,
Stack,
Text,
Title,
} from '@mantine/core';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
@@ -12,13 +19,36 @@ import { authStore } from '@/store/authStore';
export default function Validasi() {
const router = useRouter();
const [nomor, setNomor] = useState<string | null>(null);
const [otp, setOtp] = useState('');
const [loading, setLoading] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [kodeId, setKodeId] = useState<string | null>(null);
const [isRegistrationFlow, setIsRegistrationFlow] = useState(false);
// ✅ Deteksi flow dari cookie via API
useEffect(() => {
const checkFlow = async () => {
try {
const res = await fetch('/api/auth/get-flow', {
credentials: 'include'
});
const data = await res.json();
if (data.success) {
setIsRegistrationFlow(data.flow === 'register');
console.log('🔍 Flow detected from cookie:', data.flow);
}
} catch (error) {
console.error('❌ Error getting flow:', error);
setIsRegistrationFlow(false);
}
};
checkFlow();
}, []);
// Inisialisasi data OTP
useEffect(() => {
const storedKodeId = localStorage.getItem('auth_kodeId');
if (!storedKodeId) {
@@ -28,11 +58,12 @@ export default function Validasi() {
}
setKodeId(storedKodeId);
const loadOtpData = async () => {
try {
const result = await apiFetchOtpData({ kodeId: storedKodeId });
if (result.success && result.data?.nomor) {
const res = await fetch(`/api/auth/otp-data?kodeId=${encodeURIComponent(storedKodeId)}`);
const result = await res.json();
if (res.ok && result.data?.nomor) {
setNomor(result.data.nomor);
} else {
throw new Error('Data OTP tidak valid');
@@ -45,82 +76,19 @@ export default function Validasi() {
setIsLoading(false);
}
};
loadOtpData();
}, [router]);
// Verifikasi OTP
const handleVerify = async () => {
if (!kodeId || !nomor || otp.length < 4) return;
setLoading(true);
try {
const verifyResult = await apiFetchVerifyOtp({ nomor, otp, kodeId });
if (!verifyResult.success) {
// Registrasi baru?
if (
verifyResult.status === 404 &&
verifyResult.message?.includes('Akun tidak ditemukan')
) {
await handleNewRegistration();
return;
}
// Error lain
toast.error(verifyResult.message || 'Verifikasi gagal');
return;
if (isRegistrationFlow) {
await handleRegistrationVerification();
} else {
await handleLoginVerification();
}
// ✅ Verifikasi sukses → simpan user ke store
const user = verifyResult.user;
console.log('=== DEBUG USER ===');
console.log('Full user object:', user);
if (!user || !user.id) {
toast.error('Data pengguna tidak lengkap');
return;
}
const roleId = Number(user.roleId);
authStore.setUser({
id: user.id,
name: user.name || user.username || 'User',
roleId: roleId,
});
cleanupStorage();
const isUserActive = user.isActive ?? user.is_active ?? true;
// Redirect berdasarkan status approval
if (!isUserActive) {
router.replace('/waiting-room');
return;
}
// ✅ Switch statement lebih clean
let redirectPath: string;
switch (roleId) {
case 0:
case 1:
redirectPath = '/admin/landing-page/profil/program-inovasi';
break;
case 2:
redirectPath = '/admin/kesehatan/posyandu';
break;
case 3:
redirectPath = '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
break;
default:
redirectPath = '/admin';
console.warn('Unknown roleId:', roleId);
}
console.log('Redirecting to:', redirectPath);
router.replace(redirectPath);
} catch (error) {
console.error('Error saat verifikasi:', error);
toast.error('Terjadi kesalahan sistem');
@@ -129,51 +97,129 @@ export default function Validasi() {
}
};
// Registrasi baru
const handleNewRegistration = async () => {
const handleRegistrationVerification = async () => {
const username = localStorage.getItem('auth_username');
if (!username) {
toast.error('Data registrasi tidak ditemukan');
toast.error('Data registrasi tidak ditemukan.');
return;
}
try {
const res = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, username, otp, kodeId }),
});
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
if (cleanNomor.length < 10 || username.trim().length < 5) {
toast.error('Data tidak valid');
return;
}
const data = await res.json();
// ✅ Verify OTP
const verifyRes = await fetch('/api/auth/verify-otp-register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, otp, kodeId }),
credentials: 'include'
});
if (data.success) {
// Set user sementara (tanpa roleId, akan diisi saat approve)
authStore.setUser({
id: 'pending',
name: username,
roleId: 1,
});
cleanupStorage();
router.replace('/waiting-room');
} else {
toast.error(data.message || 'Registrasi gagal');
}
} catch (error) {
console.error('Error registrasi:', error);
toast.error('Gagal menyelesaikan registrasi');
const verifyData = await verifyRes.json();
if (!verifyRes.ok) {
toast.error(verifyData.message || 'Verifikasi OTP gagal');
return;
}
// ✅ Finalize registration
const finalizeRes = await fetch('/api/auth/finalize-registration', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor: cleanNomor, username, kodeId }),
credentials: 'include'
});
const data = await finalizeRes.json();
// ✅ Check JSON response (bukan redirect)
if (data.success) {
toast.success('Registrasi berhasil! Menunggu persetujuan admin.');
await cleanupStorage();
// ✅ Client-side redirect
setTimeout(() => {
window.location.href = '/waiting-room';
}, 1000);
} else {
toast.error(data.message || 'Registrasi gagal');
}
};
const cleanupStorage = () => {
const handleLoginVerification = async () => {
const loginRes = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
credentials: 'include'
});
const loginData = await loginRes.json();
if (!loginRes.ok) {
toast.error(loginData.message || 'Verifikasi gagal');
return;
}
const { id, name, roleId, isActive } = loginData.user;
authStore.setUser({
id,
name: name || 'User',
roleId: Number(roleId),
});
// ✅ Cleanup setelah login sukses
await cleanupStorage();
if (!isActive) {
window.location.href = '/waiting-room';
return;
}
const redirectPath = getRedirectPath(Number(roleId));
router.replace(redirectPath);
};
const getRedirectPath = (roleId: number): string => {
switch (roleId) {
case 0:
case 1:
case 2:
return '/admin/landing-page/profil/program-inovasi';
case 3:
return '/admin/kesehatan/posyandu';
case 4:
return '/admin/pendidikan/info-sekolah/jenjang-pendidikan';
default:
return '/admin';
}
};
// ✅ CLEANUP FUNCTION - Hapus localStorage + Cookie
const cleanupStorage = async () => {
// Clear localStorage
localStorage.removeItem('auth_kodeId');
localStorage.removeItem('auth_nomor');
localStorage.removeItem('auth_username');
// Clear cookie
try {
await fetch('/api/auth/clear-flow', {
method: 'POST',
credentials: 'include'
});
} catch (error) {
console.error('Error clearing flow cookie:', error);
}
};
const handleResend = async () => {
if (!nomor) return;
try {
const res = await fetch('/api/auth/resend-otp', {
const res = await fetch('/api/auth/resend', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor }),
@@ -190,7 +236,6 @@ export default function Validasi() {
}
};
// Loading
if (isLoading) {
return (
<Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
@@ -209,7 +254,7 @@ export default function Validasi() {
<Stack align="center" gap="lg">
<Box>
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
Kode Verifikasi
{isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
</Title>
<Text ta="center" size="sm" c="dimmed" mt="xs">
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>

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

@@ -11,21 +11,21 @@ function LayoutTabsDetail({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
const tabs = [
{
label: "Profile Desa",
value: "profiledesa",
href: "/admin/desa/profile/profile-desa",
label: "Profil Desa",
value: "profildesa",
href: "/admin/desa/profil/profil-desa",
icon: <IconUser size={18} stroke={1.8} />
},
{
label: "Profile Perbekel",
value: "profileperbekel",
href: "/admin/desa/profile/profile-perbekel",
label: "Profil Perbekel",
value: "profilperbekel",
href: "/admin/desa/profil/profil-perbekel",
icon: <IconUsers size={18} stroke={1.8} />
},
{
label: "Profile Perbekel Dari Masa Ke Masa",
value: "profile-perbekel-dari-masa-ke-masa",
href: "/admin/desa/profile/profile-perbekel-dari-masa-ke-masa",
label: "Profil Perbekel Dari Masa Ke Masa",
value: "profilperbekeldarimasakemasa",
href: "/admin/desa/profil/profil-perbekel-dari-masa-ke-masa",
icon: <IconCalendar size={18} stroke={1.8} />
}
];

View File

@@ -12,22 +12,22 @@ function LayoutTabsEdit({ children }: { children: React.ReactNode }) {
{
label: "Sejarah Desa",
value: "sejarahdesa",
href: "/admin/desa/profile/edit/sejarah_desa"
href: "/admin/desa/profil/edit/sejarah_desa"
},
{
label: "Visi Misi Desa",
value: "visimisidesa",
href: "/admin/desa/profile/edit/visi_misi_desa"
href: "/admin/desa/profil/edit/visi_misi_desa"
},
{
label: "Lambang Desa",
value: "lambangdesa",
href: "/admin/desa/profile/edit/lambang_desa"
href: "/admin/desa/profil/edit/lambang_desa"
},
{
label: "Maskot Desa",
value: "maskotdesa",
href: "/admin/desa/profile/edit/maskot_desa"
href: "/admin/desa/profil/edit/maskot_desa"
},
];
const curentTab = tabs.find(tab => tab.href === pathname)

View File

@@ -43,7 +43,7 @@ function Page() {
const id = params?.id as string;
if (!id) {
toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
return;
}
@@ -106,7 +106,7 @@ function Page() {
if (success) {
toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
} else {
toast.error('Gagal menyimpan data');
}
@@ -156,7 +156,7 @@ function Page() {
<Alert icon={<IconAlertCircle size={20} />} color="red" title="Terjadi Kesalahan" radius="md">
{loadError}
</Alert>
<Button onClick={() => router.push('/admin/desa/profile/profile-desa')} variant="outline">
<Button onClick={() => router.push('/admin/desa/profil/profil-desa')} variant="outline">
Kembali ke Halaman Utama
</Button>
</Stack>

View File

@@ -40,7 +40,7 @@ function Page() {
const id = params?.id as string;
if (!id) {
toast.error("ID tidak valid");
router.push("/admin/desa/profile/profile-desa");
router.push("/admin/desa/profil/profil-desa");
return;
}
@@ -157,7 +157,7 @@ function Page() {
if (success) {
toast.success("Maskot berhasil diperbarui!");
router.push("/admin/desa/profile/profile-desa");
router.push("/admin/desa/profil/profil-desa");
}
} catch (error) {
console.error("Error update maskot:", error);

View File

@@ -50,7 +50,7 @@ function Page() {
const id = params?.id as string;
if (!id) {
toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
return;
}
@@ -122,7 +122,7 @@ function Page() {
if (success) {
toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
} else {
toast.error('Gagal menyimpan data');
}
@@ -179,7 +179,7 @@ function Page() {
{loadError}
</Alert>
<Button
onClick={() => router.push('/admin/desa/profile/profile-desa')}
onClick={() => router.push('/admin/desa/profil/profil-desa')}
variant="outline"
>
Kembali ke Halaman Utama

View File

@@ -42,7 +42,7 @@ function Page() {
const id = params?.id as string;
if (!id) {
toast.error('ID tidak valid');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
return;
}
@@ -106,7 +106,7 @@ function Page() {
if (success) {
toast.success('Data berhasil disimpan');
router.push('/admin/desa/profile/profile-desa');
router.push('/admin/desa/profil/profil-desa');
} else {
toast.error('Gagal menyimpan data');
}
@@ -156,7 +156,7 @@ function Page() {
{loadError}
</Alert>
<Button
onClick={() => router.push('/admin/desa/profile/profile-desa')}
onClick={() => router.push('/admin/desa/profil/profil-desa')}
variant="outline"
>
Kembali ke Halaman Utama

View File

@@ -27,7 +27,7 @@ function Page() {
return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="lg">
<Title order={2} c={colors['blue-button']}>Preview Profile Desa</Title>
<Title order={2} c={colors['blue-button']}>Preview Profil Desa</Title>
{/* Sejarah Desa */}
{sejarah && (
@@ -42,7 +42,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${sejarah.id}/sejarah_desa`)}
onClick={() => router.push(`/admin/desa/profil/profil-desa/${sejarah.id}/sejarah_desa`)}
>
Edit
</Button>
@@ -87,7 +87,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${visiMisi.id}/visi_misi_desa`)}
onClick={() => router.push(`/admin/desa/profil/profil-desa/${visiMisi.id}/visi_misi_desa`)}
>
Edit
</Button>
@@ -135,7 +135,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${lambang.id}/lambang_desa`)}
onClick={() => router.push(`/admin/desa/profil/profil-desa/${lambang.id}/lambang_desa`)}
>
Edit
</Button>
@@ -180,7 +180,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-desa/${maskot.id}/maskot_desa`)}
onClick={() => router.push(`/admin/desa/profil/profil-desa/${maskot.id}/maskot_desa`)}
>
Edit
</Button>

View File

@@ -117,7 +117,7 @@ function EditPerbekelDariMasaKeMasa() {
await state.update.update();
toast.success('Perbekel dari masa ke masa berhasil diperbarui!');
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa');
} catch (error) {
console.error('Error updating perbekel dari masa ke masa:', error);
toast.error('Terjadi kesalahan saat memperbarui perbekel dari masa ke masa');

View File

@@ -25,7 +25,7 @@ function DetailPerbekelDariMasa() {
state.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/desa/profile/profile-perbekel-dari-masa-ke-masa");
router.push("/admin/desa/profil/profil-perbekel-dari-masa-ke-masa");
}
};
@@ -113,7 +113,7 @@ function DetailPerbekelDariMasa() {
<Button
color="green"
onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${data.id}/edit`)}
onClick={() => router.push(`/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/${data.id}/edit`)}
variant="light"
radius="md"
size="md"

View File

@@ -46,7 +46,7 @@ function CreatePerbekelDariMasaKeMasa() {
state.create.form.imageId = uploaded.id;
await state.create.create();
resetForm();
router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa');
router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa');
} catch (error) {
console.error(error);
toast.error('Gagal menambahkan perbekel dari masa ke masa');

View File

@@ -53,7 +53,7 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/create')}
onClick={() => router.push('/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/create')}
>
Tambah Baru
</Button>
@@ -90,7 +90,7 @@ function ListPerbekelDariMasaKeMasa({ search }: { search: string }) {
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/desa/profile/profile-perbekel-dari-masa-ke-masa/${item.id}`)}
onClick={() => router.push(`/admin/desa/profil/profil-perbekel-dari-masa-ke-masa/${item.id}`)}
>
Detail
</Button>

View File

@@ -25,7 +25,7 @@ function ProfilePerbekel() {
const id = params?.id as string;
if (!id) {
toast.error("ID tidak valid");
router.push("/admin/desa/profile/profile-perbekel");
router.push("/admin/desa/profil/profil-perbekel");
return;
}
@@ -74,7 +74,7 @@ function ProfilePerbekel() {
const success = await perbekelState.edit.submit()
if (success) {
toast.success("Data berhasil disimpan");
router.push("/admin/desa/profile/profile-perbekel");
router.push("/admin/desa/profil/profil-perbekel");
}
} catch (error) {
console.error("Error update sejarah desa:", error);

View File

@@ -41,7 +41,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/desa/profile/profile-perbekel/${perbekel.id}`)}
onClick={() => router.push(`/admin/desa/profil/profil-perbekel/${perbekel.id}`)}
>
Edit
</Button>

View File

@@ -20,9 +20,9 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
icon: <IconActivity size={18} stroke={1.8} />
},
{
label: "Grafik Hasil Kepuasan Masyarakat",
value: "grafikhasilkepuasan",
href: "/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan",
label: "Penderita Penyakit",
value: "penderitapenyakit",
href: "/admin/kesehatan/data-kesehatan-warga/penderita_penyakit",
icon: <IconGauge size={18} stroke={1.8} />
},
{

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

@@ -70,8 +70,8 @@ function EditGrafikHasilKepuasan() {
});
}
} catch (err) {
console.error("Error loading grafik hasil kepuasan:", err);
toast.error("Gagal memuat data grafik hasil kepuasan");
console.error("Error loading penderita penyakit:", err);
toast.error("Gagal memuat data penderita penyakit");
}
};
@@ -99,11 +99,11 @@ function EditGrafikHasilKepuasan() {
setIsSubmitting(true);
editState.update.form = { ...editState.update.form, ...formData };
await editState.update.submit();
toast.success('Grafik hasil kepuasan berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan');
toast.success('penderita penyakit berhasil diperbarui!');
router.push('/admin/kesehatan/data-kesehatan-warga/penderita_penyakit');
} catch (err) {
console.error('Error updating grafik hasil kepuasan:', err);
toast.error('Terjadi kesalahan saat memperbarui grafik hasil kepuasan');
console.error('Error updating penderita penyakit:', err);
toast.error('Terjadi kesalahan saat memperbarui penderita penyakit');
} finally {
setIsSubmitting(false);
}
@@ -122,7 +122,7 @@ function EditGrafikHasilKepuasan() {
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Grafik Hasil Kepuasan
Edit Penderita Penyakit
</Title>
</Group>

View File

@@ -26,7 +26,7 @@ function DetailGrafikHasilKepuasan() {
state.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan");
router.push("/admin/kesehatan/data-kesehatan-warga/penderita_penyakit");
}
};
@@ -63,7 +63,7 @@ function DetailGrafikHasilKepuasan() {
>
<Stack gap="md">
<Text fz="2xl" fw="bold" c={colors['blue-button']}>
Detail Data Grafik Hasil Kepuasan
Detail Data Penderita Penyakit
</Text>
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
@@ -118,7 +118,7 @@ function DetailGrafikHasilKepuasan() {
color="green"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/${data.id}/edit`
`/admin/kesehatan/data-kesehatan-warga/penderita_penyakit/${data.id}/edit`
)
}
variant="light"

View File

@@ -40,7 +40,7 @@ function CreateGrafikHasilKepuasanMasyarakat() {
setIsSubmitting(true);
await stateGrafikKepuasan.create.create();
resetForm();
router.push("/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan");
router.push("/admin/kesehatan/data-kesehatan-warga/penderita_penyakit");
} catch (error) {
console.error("Error creating grafik kepuasan:", error);
toast.error("Terjadi kesalahan saat membuat grafik kepuasan");
@@ -62,7 +62,7 @@ function CreateGrafikHasilKepuasanMasyarakat() {
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Grafik Hasil Kepuasan Masyarakat
Tambah Penderita Penyakit
</Title>
</Group>

View File

@@ -36,7 +36,7 @@ function GrafikHasilKepuasanMasyarakat() {
<Box>
{/* Header Search */}
<HeaderSearch
title='Grafik Hasil Kepuasan Masyarakat'
title='Penderita Penyakit'
placeholder='Cari nama atau alamat...'
searchIcon={<IconSearch size={20} />}
value={search}
@@ -115,14 +115,14 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
<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 Grafik Hasil Kepuasan Masyarakat</Title>
<Title order={4}>Daftar Penderita Penyakit</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() =>
router.push(
'/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/create'
'/admin/kesehatan/data-kesehatan-warga/penderita_penyakit/create'
)
}
>
@@ -176,7 +176,7 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
color="blue"
onClick={() =>
router.push(
`/admin/kesehatan/data-kesehatan-warga/grafik_hasil_kepuasan/${item.id}`
`/admin/kesehatan/data-kesehatan-warga/penderita_penyakit/${item.id}`
)
}
>
@@ -221,7 +221,7 @@ function ListGrafikHasilKepuasanMasyarakat({ search }: { search: string }) {
{/* Chart */}
<Box mt="lg" style={{ width: '100%', minWidth: 300, height: 420, minHeight: 300 }}>
<Paper withBorder bg={colors['white-1']} p={'md'}>
<Title pb={10} order={4}>Grafik Hasil Kepuasan Masyarakat</Title>
<Title pb={10} order={4}>Penderita Penyakit</Title>
{mounted && diseaseChartData.length > 0 ? (
<Center>
<BarChart

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,12 +30,13 @@ function Page() {
return (
<Paper bg={colors['white-1']} p="lg" radius="md" shadow="sm">
<Stack gap="md">
<Grid align="center">
<Grid>
<GridCol span={{ base: 12, md: 11 }}>
<Title order={3} c={colors['blue-button']}>Preview Profil PPID</Title>
</GridCol>
<GridCol span={{ base: 12, md: 1 }}>
<Button
w={{base: '100%', md: "110%"}}
c="green"
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}

View File

@@ -1,22 +1,34 @@
// src/app/admin/(dashboard)/user&role/_com/dynamicNavbar.ts
import { navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
import { devBar, navBar, role1, role2, role3 } from '@/app/admin/_com/list_PageAdmin';
// ✅ Helper: normalisasi ID menu agar konsisten
const normalizeMenuId = (id: string): string => {
return id.trim().toLowerCase();
};
export function getNavbar({
roleId,
menuIds,
}: {
roleId: number; // pastikan number
menuIds?: string[] | null; // opsional
roleId: number;
menuIds?: string[] | null;
}) {
// Prioritas: menuIds > roleId
// ✅ Jika menuIds tersedia, gunakan untuk filter — dengan normalisasi
if (menuIds && menuIds.length > 0) {
return navBar.filter(section => menuIds.includes(section.id));
// Normalisasi semua menuIds dari DB/state
const normalizedMenuSet = new Set(menuIds.map(id => normalizeMenuId(id)));
return navBar.filter(section => {
const normalizedSectionId = normalizeMenuId(section.id);
return normalizedMenuSet.has(normalizedSectionId);
});
}
// Fallback ke role-based
if (roleId === 0) return navBar;
if (roleId === 1) return role1;
if (roleId === 2) return role2;
if (roleId === 3) return role3;
// 🔁 Fallback ke role-based navigation
if (roleId === 0) return devBar;
if (roleId === 1) return navBar;
if (roleId === 2) return role1;
if (roleId === 3) return role2;
if (roleId === 4) return role3;
return [];
}

View File

@@ -8,6 +8,7 @@ import { Button, Checkbox, Group, Paper, Select, Stack, Text, Title } from '@man
import { useEffect, useState } from 'react'
import { useProxy } from 'valtio/utils'
import user from '../../_state/user/user-state'
import { useShallowEffect } from '@mantine/hooks'
// ✅ Helper: ekstrak semua menu ID dari struktur navBar
@@ -23,6 +24,10 @@ function MenuAccessPage() {
const [selectedUserId, setSelectedUserId] = useState<string | null>(null)
const [userMenus, setUserMenus] = useState<string[]>([])
useShallowEffect(() => {
stateUser.findMany.load()
}, [])
// ✅ Gunakan helper untuk ekstrak menu
const availableMenus = extractMenuIds(navBar);

View File

@@ -8,6 +8,7 @@ import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import user from '../../_state/user/user-state';
import { authStore } from '@/store/authStore';
function User() {
const [search, setSearch] = useState("");
@@ -92,27 +93,21 @@ function ListUser({ search }: { search: string }) {
const success = await stateUser.update.submit({
id: userId,
roleId: newRoleId,
});
if (success) {
// Cek apakah role berubah
const res = await fetch('/api/user/updt', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
id: userId,
roleId: newRoleId,
}),
});
const data = await res.json();
if (data.roleChanged) {
// Tampilkan notifikasi
alert(`User ${username} akan logout otomatis!`);
// ✅ Logout user jika sedang mengedit diri sendiri
const currentUserId = authStore.user?.id;
if (currentUserId === userId) {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
alert("Perubahan memerlukan login ulang");
window.location.href = "/login";
return;
}
// Reload data
stateUser.findMany.load(page, 10, search);
}
@@ -127,13 +122,25 @@ function ListUser({ search }: { search: string }) {
});
if (success) {
// ✅ Logout user jika sedang mengedit diri sendiri
const currentUserId = authStore.user?.id;
if (currentUserId === userId) {
authStore.setUser(null);
document.cookie = `${process.env.BASE_SESSION_KEY}=; Max-Age=0; path=/;`;
alert("Perubahan memerlukan login ulang");
window.location.href = "/login";
return;
}
// Reload data
stateUser.findMany.load(page, 10, search);
}
};
const filteredData = (data || []).filter(
(item) => item.roleId !== "0" // asumsikan id role SUPERADMIN = "0"
);
const filteredData = (data || []).filter((item) => {
return item.roleId !== "0" && item.roleId !== "1";
});
if (loading || !data) {
return (
@@ -178,7 +185,7 @@ function ListUser({ search }: { search: string }) {
<Select
placeholder="Pilih role"
data={stateRole.findMany.data
.filter(r => r.id !== "0") // ❌ Sembunyikan SUPERADMIN
.filter(r => r.id !== "0" && r.id !== "1") // ❌ Sembunyikan SUPERADMIN dan DEVELOPER
.map(r => ({
label: r.name,
value: r.id,

View File

@@ -1,3 +1,407 @@
export const devBar = [
{
id: "Landing Page",
name: "Landing Page",
path: "",
children: [
{
id: "Landing_Page_1",
name: "Profil",
path: "/admin/landing-page/profil/program-inovasi"
},
{
id: "Landing_Page_2",
name: "Desa Anti Korupsi",
path: "/admin/landing-page/desa-anti-korupsi/list-desa-anti-korupsi"
},
{
id: "Landing_Page_3",
name: "Indeks Kepuasan Masyarakat",
path: "/admin/landing-page/indeks-kepuasan-masyarakat/grafik-kepuasan-masyarakat"
},
{
id: "Landing_Page_4",
name: "SDGs",
path: "/admin/landing-page/SDGs"
},
{
id: "Landing_Page_5",
name: "APBDes",
path: "/admin/landing-page/apbdes"
},
{
id: "Landing_Page_6",
name: "Prestasi Desa",
path: "/admin/landing-page/prestasi-desa/list-prestasi-desa"
}
]
},
{
id: "PPID",
name: "PPID",
path: "",
children: [
{
id: "PPID_1",
name: "Profil PPID",
path: "/admin/ppid/profil-ppid"
},
{
id: "PPID_2",
name: "Struktur PPID",
path: "/admin/ppid/struktur-ppid/pegawai"
},
{
id: "PPID_3",
name: "Visi Misi PPID",
path: "/admin/ppid/visi-misi-ppid"
},
{
id: "PPID_4",
name: "Dasar Hukum",
path: "/admin/ppid/dasar-hukum"
},
{
id: "PPID_5",
name: "Permohonan Informasi Publik",
path: "/admin/ppid/permohonan-informasi-publik"
},
{
id: "PPID_6",
name: "Permohonan Keberatan Informasi Publik",
path: "/admin/ppid/permohonan-keberatan-informasi-publik"
},
{
id: "PPID_7",
name: "Daftar Informasi Publik",
path: "/admin/ppid/daftar-informasi-publik"
},
{
id: "PPID_8",
name: "Indeks Kepuasan Masyarakat",
path: "/admin/ppid/indeks-kepuasan-masyarakat/grafik-kepuasan-masyarakat"
},
]
},
{
id: "Desa",
name: "Desa",
path: "",
children: [
{
id: "Desa_1",
name: "Profil",
path: "/admin/desa/profil/profil-desa"
},
{
id: "Desa_2",
name: "Potensi",
path: "/admin/desa/potensi/list-potensi"
},
{
id: "Desa_3",
name: "Berita",
path: "/admin/desa/berita/list-berita"
},
{
id: "Desa_4",
name: "Pengumuman",
path: "/admin/desa/pengumuman/list-pengumuman"
},
{
id: "Desa_5",
name: "Gallery",
path: "/admin/desa/gallery/foto"
},
{
id: "Desa_6",
name: "Layanan",
path: "/admin/desa/layanan/pelayanan_surat_keterangan"
},
{
id: "Desa_7",
name: "Penghargaan",
path: "/admin/desa/penghargaan"
}
]
},
{
id: "Kesehatan",
name: "Kesehatan",
path: "",
children: [
{
id: "Kesehatan_1",
name: "Posyandu",
path: "/admin/kesehatan/posyandu"
},
{
id: "Kesehatan_2",
name: "Data Kesehatan Warga",
path: "/admin/kesehatan/data-kesehatan-warga/persentase_data_kelahiran_kematian"
},
{
id: "Kesehatan_3",
name: "Puskesmas",
path: "/admin/kesehatan/puskesmas"
},
{
id: "Kesehatan_4",
name: "Program Kesehatan",
path: "/admin/kesehatan/program-kesehatan"
},
{
id: "Kesehatan_5",
name: "Penanganan Darurat",
path: "/admin/kesehatan/penanganan-darurat"
},
{
id: "Kesehatan_6",
name: "Kontak Darurat",
path: "/admin/kesehatan/kontak-darurat"
},
{
id: "Kesehatan_7",
name: "Info Wabah/Penyakit",
path: "/admin/kesehatan/info-wabah-penyakit"
}
]
},
{
id: "Keamanan",
name: "Keamanan",
path: "",
children: [
{
id: "Keamanan_1",
name: "Keamanan Lingkungan (Pecalang/Patwal)",
path: "/admin/keamanan/keamanan-lingkungan-pecalang-patwal"
},
{
id: "Keamanan_2",
name: "Polsek Terdekat",
path: "/admin/keamanan/polsek-terdekat"
},
{
id: "Keamanan_3",
name: "Kontak Darurat",
path: "/admin/keamanan/kontak-darurat/kontak-darurat-keamanan"
},
{
id: "Keamanan_4",
name: "Pencegahan Kriminalitas",
path: "/admin/keamanan/pencegahan-kriminalitas"
},
{
id: "Keamanan_5",
name: "Laporan Publik",
path: "/admin/keamanan/laporan-publik"
},
{
id: "Keamanan_6",
name: "Tips Keamanan",
path: "/admin/keamanan/tips-keamanan"
}
]
},
{
id: "Ekonomi",
name: "Ekonomi",
path: "",
children: [
{
id: "Ekonomi_1",
name: "Pasar Desa",
path: "/admin/ekonomi/pasar-desa/produk-pasar-desa"
},
{
id: "Ekonomi_2",
name: "Lowongan Kerja Lokal",
path: "/admin/ekonomi/lowongan-kerja-lokal"
},
{
id: "Ekonomi_3",
name: "Struktur Organisasi Dan Sk Pengurus Bumdesa",
path: "/admin/ekonomi/struktur-organisasi-dan-sk-pengurus-bumdesa/pegawai"
},
{
id: "Ekonomi_4",
name: "PADesa (Pendapatan Asli Desa)",
path: "/admin/ekonomi/PADesa-pendapatan-asli-desa/apbdesa"
},
{
id: "Ekonomi_5",
name: "Jumlah Pengangguran",
path: "/admin/ekonomi/jumlah-pengangguran"
},
{
id: "Ekonomi_6",
name: "Jumlah penduduk usia kerja yang menganggur",
path: "/admin/ekonomi/jumlah-penduduk-usia-kerja-yang-menganggur/pengangguran_berdasarkan_usia"
},
{
id: "Ekonomi_7",
name: "Jumlah Penduduk Miskin",
path: "/admin/ekonomi/jumlah-penduduk-miskin"
},
{
id: "Ekonomi_8",
name: "Program Kemiskinan",
path: "/admin/ekonomi/program-kemiskinan"
},
{
id: "Ekonomi_9",
name: "Sektor Unggulan Desa",
path: "/admin/ekonomi/sektor-unggulan-desa"
},
{
id: "Ekonomi_10",
name: "Demografi Pekerjaan",
path: "/admin/ekonomi/demografi-pekerjaan"
}
]
}, {
id: "Inovasi",
name: "Inovasi",
path: "",
children: [
{
id: "Inovasi_1",
name: "Desa Digital/Smart Village",
path: "/admin/inovasi/desa-digital-smart-village"
},
{
id: "Inovasi_2",
name: "Layanan Online Desa",
path: "/admin/inovasi/layanan-online-desa/administrasi-online"
},
{
id: "Inovasi_3",
name: "Program Kreatif Desa",
path: "/admin/inovasi/program-kreatif-desa"
},
{
id: "Inovasi_4",
name: "Kolaborasi Inovasi",
path: "/admin/inovasi/kolaborasi-inovasi/list-kolaborasi-inovasi"
},
{
id: "Inovasi_5",
name: "Info Teknologi Tepat Guna",
path: "/admin/inovasi/info-teknologi-tepat-guna"
},
{
id: "Inovasi_6",
name: "Ajukan Ide Inovatif",
path: "/admin/inovasi/ajukan-ide-inovatif"
}
]
}, {
id: "Lingkungan",
name: "Lingkungan",
path: "",
children: [
{
id: "Lingkungan_1",
name: "Pengelolaan Sampah (Bank Sampah)",
path: "/admin/lingkungan/pengelolaan-sampah-bank-sampah/list-pengelolaan-sampah-bank-sampah"
},
{
id: "Lingkungan_2",
name: "Program Penghijauan",
path: "/admin/lingkungan/program-penghijauan"
},
{
id: "Lingkungan_3",
name: "Data Lingkungan Desa",
path: "/admin/lingkungan/data-lingkungan-desa"
},
{
id: "Lingkungan_4",
name: "Gotong Royong",
path: "/admin/lingkungan/gotong-royong/kegiatan-desa"
},
{
id: "Lingkungan_5",
name: "Edukasi Lingkungan",
path: "/admin/lingkungan/edukasi-lingkungan/tujuan-edukasi-lingkungan"
},
{
id: "Lingkungan_6",
name: "Konservasi Adat Bali",
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
}
]
},
{
id: "Pendidikan",
name: "Pendidikan",
path: "",
children: [
{
id: "Pendidikan_1",
name: "Info Sekolah",
path: "/admin/pendidikan/info-sekolah/jenjang-pendidikan"
},
{
id: "Pendidikan_2",
name: "Beasiswa Desa",
path: "/admin/pendidikan/beasiswa-desa/beasiswa-pendaftar"
},
{
id: "Pendidikan_3",
name: "Program Pendidikan Anak",
path: "/admin/pendidikan/program-pendidikan-anak/program-unggulan"
},
{
id: "Pendidikan_4",
name: "Bimbingan Belajar Desa",
path: "/admin/pendidikan/bimbingan-belajar-desa/tujuan-program"
},
{
id: "Pendidikan_5",
name: "Pendidikan Non Formal",
path: "/admin/pendidikan/pendidikan-non-formal/tujuan-program"
},
{
id: "Pendidikan_6",
name: "Perpustakaan Digital",
path: "/admin/pendidikan/perpustakaan-digital/data-perpustakaan"
},
{
id: "Pendidikan_7",
name: "Data Pendidikan",
path: "/admin/pendidikan/data-pendidikan"
}
]
},
{
id: "User & Role",
name: "User & Role",
path: "",
children: [
{
id: "User",
name: "User",
path: "/admin/user&role/user"
},
{
id: "Role",
name: "Role",
path: "/admin/user&role/role"
},
{
id: "Menu Access",
name: "Menu Access",
path: "/admin/user&role/menu-access"
}
]
}
]
export const navBar = [
{
id: "Landing Page",
@@ -91,8 +495,8 @@ export const navBar = [
children: [
{
id: "Desa_1",
name: "Profile",
path: "/admin/desa/profile/profile-desa"
name: "Profil",
path: "/admin/desa/profil/profil-desa"
},
{
id: "Desa_2",
@@ -495,8 +899,8 @@ export const role1 = [
children: [
{
id: "Desa_1",
name: "Profile",
path: "/admin/desa/profile/profile-desa"
name: "Profil",
path: "/admin/desa/profil/profil-desa"
},
{
id: "Desa_2",

View File

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

View File

@@ -6,33 +6,24 @@ import path from "path";
const beritaDelete = async (context: Context) => {
const id = context.params?.id as string;
if (!id) {
return {
status: 400,
body: "ID tidak diberikan",
};
}
if (!id) return { status: 400, body: "ID tidak diberikan" };
const berita = await prisma.berita.findUnique({
where: { id },
include: {
image: true,
kategoriBerita: true, // pastikan relasi image sudah ada di prisma schema
},
include: { image: true, kategoriBerita: true },
});
if (!berita) {
return {
status: 404,
body: "Berita tidak ditemukan",
};
}
if (!berita) return { status: 404, body: "Berita tidak ditemukan" };
// Hapus file gambar dari filesystem jika ada
// 1. HAPUS BERITA DULU
await prisma.berita.delete({ where: { id } });
// 2. BARU HAPUS FILE
if (berita.image) {
try {
const filePath = path.join(berita.image.path, berita.image.name);
await fs.unlink(filePath);
await prisma.fileStorage.delete({
where: { id: berita.image.id },
});
@@ -41,15 +32,11 @@ const beritaDelete = async (context: Context) => {
}
}
// Hapus berita dari DB
await prisma.berita.delete({
where: { id },
});
return {
success: true,
message: "Berita dan file terkait berhasil dihapus",
};
};
export default beritaDelete;

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

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

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import Elysia from "elysia";
import DaftarInformasiPublik from "./daftar_informasi_publik";
import GrafikHasilKepuasanMasyarakat from "./ikm/grafik_hasil_kepuasan_masyarakat";
import GrafikBerdasarkanJenisKelamin from "./ikm/grafik_berdasarkan_jenis_kelamin";
import GrafikBerdasarkanResponden from "./ikm/grafik_responden";
import GrafikBerdasarkanUmur from "./ikm/grafik_berdasarkan_umur";
@@ -10,6 +9,7 @@ import ProfilePPID from "./profile_ppid";
import VisiMisiPPID from "./visi_misi_ppid/visi_misi_ppid";
import DasarHukumPPID from "./dasar_hukum";
import StrukturPPID from "./struktur_ppid";
import GrafikHasilKepuasanMasyarakat from "./ikm/grafik_hasil_kepuasan_masyarakat";

View File

@@ -3,39 +3,55 @@ import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.PermohonanInformasiPublikGetPayload<{
select: {
name: true;
nik: true;
email: true;
notelp: true;
alamat: true;
jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true;
}
}>
export default async function permohonanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
await prisma.permohonanInformasiPublik.create({
data: {
name: body.name,
nik: body.nik,
email: body.email,
notelp: body.notelp,
alamat: body.alamat,
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
}
})
select: {
name: true;
nik: true;
email: true;
notelp: true;
alamat: true;
jenisInformasiDimintaId: true;
caraMemperolehInformasiId: true;
caraMemperolehSalinanInformasiId: true;
};
}>;
export default async function permohonanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
// ========== VALIDASI NIK ==========
if (body.nik && body.nik.length > 16) {
return {
success: true,
message: "Permohonan Informasi Publik Berhasil Dibuat",
data: {
...body,
}
}
success: false,
status: 400,
message: "Maksimal NIK adalah 16 angka",
};
}
// ========== VALIDASI NOMOR TELEPON ==========
if (body.notelp && body.notelp.length > 15) {
return {
success: false,
status: 400,
message: "Maksimal nomor telepon adalah 15 angka",
};
}
await prisma.permohonanInformasiPublik.create({
data: {
name: body.name,
nik: body.nik,
email: body.email,
notelp: body.notelp,
alamat: body.alamat,
jenisInformasiDimintaId: body.jenisInformasiDimintaId,
caraMemperolehInformasiId: body.caraMemperolehInformasiId,
caraMemperolehSalinanInformasiId: body.caraMemperolehSalinanInformasiId,
},
});
return {
success: true,
message: "Permohonan Informasi Publik Berhasil Dibuat",
data: { ...body },
};
}

View File

@@ -3,31 +3,42 @@ import { Prisma } from "@prisma/client";
import { Context } from "elysia";
type FormCreate = Prisma.FormulirPermohonanKeberatanGetPayload<{
select: {
name: true;
email: true;
notelp: true;
alasan: true;
}
}>
select: {
name: true;
email: true;
notelp: true;
alasan: true;
};
}>;
export default async function permohonanKeberatanInformasiPublikCreate(context: Context) {
const body = context.body as FormCreate;
await prisma.formulirPermohonanKeberatan.create({
data: {
name: body.name,
email: body.email,
notelp: body.notelp,
alasan: body.alasan,
}
})
export default async function permohonanKeberatanInformasiPublikCreate(
context: Context
) {
const body = context.body as FormCreate;
// ========== VALIDASI NOMOR TELEPON ==========
if (body.notelp && body.notelp.length > 15) {
return {
success: true,
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat",
data: {
...body,
}
}
}
success: false,
status: 400,
message: "Maksimal nomor telepon adalah 15 angka",
};
}
await prisma.formulirPermohonanKeberatan.create({
data: {
name: body.name,
email: body.email,
notelp: body.notelp,
alasan: body.alasan,
},
});
return {
success: true,
message: "Permohonan Keberatan Informasi Publik Berhasil Dibuat",
data: {
...body,
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
// /api/user/delete.ts
// /api/user/delUser.ts
import prisma from '@/lib/prisma';
import { Context } from 'elysia';
export default async function userDelete(context: Context) {
export default async function userDeleteAccount(context: Context) {
const { id } = context.params as { id: string };
try {
// Cek user dulu
// 1. Cek user dulu
const existingUser = await prisma.user.findUnique({
where: { id },
});
@@ -18,15 +18,39 @@ export default async function userDelete(context: Context) {
};
}
// Hard delete (hapus permanen)
const deletedUser = await prisma.user.delete({
where: { id },
// ✅ 2. Hapus SEMUA relasi dalam TRANSACTION
const result = await prisma.$transaction(async (tx) => {
// Hapus UserSession
const deletedSessions = await tx.userSession.deleteMany({
where: { userId: id },
});
// ✅ Hapus UserMenuAccess
const deletedMenuAccess = await tx.userMenuAccess.deleteMany({
where: { userId: id },
});
// ✅ Tambahkan relasi lain jika ada (contoh):
// await tx.userLog.deleteMany({ where: { userId: id } });
// await tx.userNotification.deleteMany({ where: { userId: id } });
// await tx.userToken.deleteMany({ where: { userId: id } });
// Hapus user
const deletedUser = await tx.user.delete({
where: { id },
});
return {
user: deletedUser,
sessionsDeleted: deletedSessions.count,
menuAccessDeleted: deletedMenuAccess.count,
};
});
return {
success: true,
message: 'User berhasil dihapus permanen',
data: deletedUser,
message: `User berhasil dihapus permanen (${result.sessionsDeleted} session, ${result.menuAccessDeleted} menu access)`,
data: result,
};
} catch (error) {
console.error('Error delete user:', error);
@@ -35,4 +59,4 @@ export default async function userDelete(context: Context) {
message: 'Terjadi kesalahan saat menghapus user',
};
}
}
}

View File

@@ -5,6 +5,7 @@ import userFindMany from "./findMany";
import userFindUnique from "./findUnique";
import userDelete from "./del"; // `delete` nggak boleh jadi nama file JS langsung, jadi biasanya `del.ts`
import userUpdate from "./updt";
import userDeleteAccount from "./delUser";
const User = new Elysia({ prefix: "/api/user" })
.get("/findMany", userFindMany)
@@ -25,7 +26,7 @@ const User = new Elysia({ prefix: "/api/user" })
})
}
)
.put("/delUser/:id", userDelete, {
.delete("/delUser/:id", userDeleteAccount, {
params: t.Object({
id: t.String(),
}),

View File

@@ -1,171 +1,54 @@
// /* eslint-disable @typescript-eslint/no-explicit-any */
// import prisma from "@/lib/prisma";
// import { Context } from "elysia";
// export default async function userUpdate(context: Context) {
// try {
// const { id, isActive, roleId } = await context.body as {
// id: string,
// isActive?: boolean,
// roleId?: string
// };
// if (!id) {
// return {
// success: false,
// message: "ID user wajib ada",
// };
// }
// // Optional: cek apakah roleId valid
// if (roleId) {
// const cekRole = await prisma.role.findUnique({
// where: { id: roleId }
// });
// if (!cekRole) {
// return {
// success: false,
// message: "Role tidak ditemukan",
// };
// }
// }
// // ✅ CEK: Apakah roleId berubah?
// let isRoleChanged = false;
// let oldRoleId: string | null = null;
// if (roleId) {
// const currentUser = await prisma.user.findUnique({
// where: { id },
// select: {
// roleId: true,
// username: true,
// }
// });
// if (currentUser && currentUser.roleId !== roleId) {
// isRoleChanged = true;
// oldRoleId = currentUser.roleId;
// console.log(`🔄 Role berubah untuk ${currentUser.username}: ${oldRoleId} → ${roleId}`);
// }
// }
// // Update user
// const updatedUser = await prisma.user.update({
// where: { id },
// data: {
// ...(isActive !== undefined && { isActive }),
// ...(roleId && { roleId }),
// },
// select: {
// id: true,
// username: true,
// nomor: true,
// isActive: true,
// roleId: true,
// updatedAt: true,
// role: {
// select: {
// id: true,
// name: true,
// }
// }
// }
// });
// // ✅ FORCE LOGOUT: Hapus UserSession jika role berubah
// if (isRoleChanged) {
// try {
// const deletedSessions = await prisma.userSession.deleteMany({
// where: { userId: id }
// });
// console.log(`🔒 Force logout user ${updatedUser.username} (${id})`);
// console.log(` Deleted ${deletedSessions.count} session(s)`);
// console.log(` Role: ${oldRoleId} → ${roleId}`);
// } catch (sessionError: any) {
// // Jika UserSession tidak ditemukan (user belum pernah login), skip error
// if (sessionError.code !== 'P2025') {
// console.error("⚠️ Error menghapus session:", sessionError);
// } else {
// console.log(` User ${updatedUser.username} belum pernah login`);
// }
// }
// }
// // ✅ Response dengan info tambahan
// return {
// success: true,
// message: isRoleChanged
// ? `User berhasil diupdate. ${updatedUser.username} akan logout otomatis.`
// : "User berhasil diupdate",
// data: updatedUser,
// roleChanged: isRoleChanged, // Info untuk frontend
// oldRoleId: oldRoleId,
// newRoleId: roleId,
// };
// } catch (e: any) {
// console.error("❌ Error update user:", e);
// return {
// success: false,
// message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
// };
// }
// }
/* eslint-disable @typescript-eslint/no-explicit-any */
import prisma from "@/lib/prisma";
import { Context } from "elysia";
// API update user (Elysia atau Next.js API Route)
// API update user
export default async function userUpdate(context: Context) {
try {
const { id, isActive, roleId } = await context.body as {
id: string,
isActive?: boolean,
roleId?: string
const { id, isActive, roleId } = (await context.body) as {
id: string;
isActive?: boolean;
roleId?: string;
};
if (!id) {
return {
success: false,
message: "ID user wajib ada",
};
return { success: false, message: "ID user wajib ada" };
}
// Cek apakah roleId valid
// Validasi role
if (roleId) {
const cekRole = await prisma.role.findUnique({ where: { id: roleId } });
if (!cekRole) {
return {
success: false,
message: "Role tidak ditemukan",
};
}
const role = await prisma.role.findUnique({ where: { id: roleId } });
if (!role) return { success: false, message: "Role tidak ditemukan" };
}
// Deteksi perubahan role
let isRoleChanged = false;
if (roleId) {
const currentUser = await prisma.user.findUnique({
where: { id },
select: { roleId: true }
const currentUser = await prisma.user.findUnique({
where: { id },
select: { roleId: true, isActive: true },
});
if (!currentUser) {
return { success: false, message: "User tidak ditemukan" };
}
const isRoleChanged = roleId && currentUser.roleId !== roleId;
const isActiveChanged =
isActive !== undefined && currentUser.isActive !== isActive;
// ✅ Jika role berubah, hapus semua akses menu yang ada
if (isRoleChanged) {
await prisma.userMenuAccess.deleteMany({
where: { userId: id }
});
isRoleChanged = currentUser?.roleId !== roleId;
}
// ✅ UPDATE USER + INVALIDATE SESSION
// Update user
const updatedUser = await prisma.user.update({
where: { id },
data: {
...(isActive !== undefined && { isActive }),
...(roleId && { roleId }),
// Force logout: set sessionInvalid = true
...(isRoleChanged && { sessionInvalid: true }),
// Force logout: invalidate semua sesi
...(isRoleChanged ? { sessionInvalid: true } : {}),
},
select: {
id: true,
@@ -173,30 +56,28 @@ export default async function userUpdate(context: Context) {
nomor: true,
isActive: true,
roleId: true,
role: { select: { name: true } }
}
role: { select: { name: true } },
},
});
// ✅ Reset sessionInvalid setelah 5 detik (opsional)
// ✅ HAPUS SEMUA SESI USER DI DATABASE
if (isRoleChanged) {
setTimeout(async () => {
try {
await prisma.user.update({
where: { id },
data: { sessionInvalid: false }
});
} catch (e) {
console.error('Gagal reset sessionInvalid:', e);
}
}, 5000);
await prisma.userSession.deleteMany({ where: { userId: id } });
}
return {
success: true,
message: isRoleChanged
? `User berhasil diupdate. ${updatedUser.username} akan logout otomatis.`
: "User berhasil diupdate",
roleChanged: isRoleChanged,
isActiveChanged,
data: updatedUser,
message: isRoleChanged
? `Role ${updatedUser.username} diubah. User akan logout otomatis.`
: isActiveChanged
? `${updatedUser.username} ${
isActive ? "diaktifkan" : "dinonaktifkan"
}.`
: "User berhasil diupdate",
};
} catch (e: any) {
console.error("❌ Error update user:", e);
@@ -205,4 +86,4 @@ export default async function userUpdate(context: Context) {
message: "Gagal mengupdate user: " + (e.message || "Unknown error"),
};
}
}
}

View File

@@ -14,6 +14,7 @@ export const apiFetchLogin = async ({ nomor }: { nomor: string }) => {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nomor: cleanPhone }),
credentials: 'include'
});
// Pastikan respons bisa di-parse sebagai JSON
@@ -58,6 +59,7 @@ export const apiFetchRegister = async ({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim(), nomor: cleanPhone }),
credentials: 'include',
});
const data = await response.json();
@@ -86,32 +88,14 @@ export const apiFetchOtpData = async ({ kodeId }: { kodeId: string }) => {
return data;
};
export const apiFetchVerifyOtp = async ({
nomor,
otp,
kodeId
}: {
nomor: string;
otp: string;
kodeId: string;
}) => {
if (!nomor || !otp || !kodeId) {
throw new Error('Data verifikasi tidak lengkap');
}
if (!/^\d{4,6}$/.test(otp)) {
throw new Error('Kode OTP harus 4-6 digit angka');
}
const response = await fetch('/api/auth/verify-otp', {
// Ganti endpoint ke verify-otp-login
export const apiFetchVerifyOtp = async ({ nomor, otp, kodeId }: { nomor: string; otp: string; kodeId: string }) => {
const response = await fetch('/api/auth/verify-otp-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ nomor, otp, kodeId }),
});
const data = await response.json();
// ✅ Jangan throw error untuk status 4xx — biarkan frontend handle
return {
success: response.ok,
...data,

View File

@@ -1,85 +1,59 @@
// app/api/auth/_lib/session_create.ts
// src/app/api/auth/_lib/sessionCreate.ts
import { cookies } from "next/headers";
import { encrypt } from "./encrypt";
import prisma from "@/lib/prisma";
import { add } from "date-fns";
export async function sessionCreate({
sessionKey,
exp = "30 day",
jwtSecret,
user,
invalidatePrevious = true, // 🔑 kontrol apakah sesi lama di-nonaktifkan
}: {
sessionKey: string;
exp?: string;
jwtSecret: string;
user: Record<string, unknown>;
user: Record<string, unknown> & { id: string };
invalidatePrevious?: boolean; // default true untuk login, false untuk registrasi
}) {
// 🔒 Validasi kunci tidak kosong
// Validasi env vars
if (!sessionKey || sessionKey.length === 0) {
throw new Error("sessionKey tidak boleh kosong");
}
if (!jwtSecret || jwtSecret.length === 0) {
throw new Error("jwtSecret tidak boleh kosong");
if (!jwtSecret || jwtSecret.length < 32) {
throw new Error("jwtSecret minimal 32 karakter");
}
const token = await encrypt({
exp,
jwtSecret,
user,
});
if (token === null) {
const token = await encrypt({ exp, jwtSecret, user });
if (!token) {
throw new Error("Token generation failed");
}
// ✅ HYBRID: Simpan token ke database UserSession
const userId = user.id as string;
if (userId) {
try {
// Hapus session lama user ini (logout device lain)
await prisma.userSession.deleteMany({
where: { userId },
});
// ✅ Hitung expiresAt
let expiresAt = add(new Date(), { days: 30 });
if (exp === "7 day") expiresAt = add(new Date(), { days: 7 });
// Parse expiration
const expiresDate = new Date();
const expMatch = exp.match(/(\d+)\s*(day|year)/);
if (expMatch) {
const [, num, unit] = expMatch;
const amount = parseInt(num);
if (unit === 'year') {
expiresDate.setFullYear(expiresDate.getFullYear() + amount);
} else if (unit === 'day') {
expiresDate.setDate(expiresDate.getDate() + amount);
}
} else {
// Default 30 hari
expiresDate.setDate(expiresDate.getDate() + 30);
}
// Buat session baru di database
await prisma.userSession.create({
data: {
userId,
token, // JWT token disimpan
expires: expiresDate,
active: true,
},
});
console.log(`✅ Session created for user ${userId}`);
} catch (dbError) {
console.error("⚠️ Error menyimpan session ke database:", dbError);
// Tetap lanjut meski gagal simpan ke DB (fallback ke JWT only)
}
// 🔐 Hanya nonaktifkan sesi aktif sebelumnya jika diminta (misal: saat login ulang)
if (invalidatePrevious) {
await prisma.userSession.updateMany({
where: { userId: user.id, active: true },
data: { active: false },
});
}
// Set cookie
const cookieStore = await cookies();
cookieStore.set(sessionKey, token, {
// ✅ Simpan sesi baru
await prisma.userSession.create({
data: {
token,
userId: user.id,
active: true,
expiresAt,
},
});
// ✅ Set cookie
(await cookies()).set(sessionKey, token, {
httpOnly: true,
sameSite: "lax",
path: "/",

View File

@@ -1,134 +1,37 @@
// // app/api/auth/_lib/session_verify.ts
// import { cookies } from 'next/headers';
// import { decrypt } from './decrypt';
// import prisma from '@/lib/prisma';
// /**
// * Verifikasi session hybrid:
// * 1. Decrypt JWT token
// * 2. Cek apakah token masih ada di database (untuk force logout)
// * 3. Return data user terbaru dari database
// */
// export async function verifySession(): Promise<Record<string, unknown> | null> {
// try {
// const sessionKey = process.env.BASE_SESSION_KEY;
// if (!sessionKey) {
// throw new Error('BASE_SESSION_KEY tidak ditemukan di environment');
// }
// const jwtSecret = process.env.BASE_TOKEN_KEY;
// if (!jwtSecret) {
// throw new Error('BASE_TOKEN_KEY tidak ditemukan di environment');
// }
// const cookieStore = await cookies();
// const token = cookieStore.get(sessionKey)?.value;
// if (!token) {
// return null;
// }
// // Step 1: Decrypt JWT
// const jwtUser = await decrypt({ token, jwtSecret });
// if (!jwtUser || !jwtUser.id) {
// console.log('⚠️ JWT decrypt failed atau tidak ada user ID');
// return null;
// }
// // Step 2: Cek database UserSession (untuk force logout)
// try {
// const dbSession = await prisma.userSession.findFirst({
// where: {
// userId: jwtUser.id as string,
// token: token,
// active: true,
// OR: [
// { expires: null },
// { expires: { gte: new Date() } },
// ],
// },
// include: {
// User: {
// select: {
// id: true,
// username: true,
// nomor: true,
// roleId: true,
// isActive: true,
// },
// },
// },
// });
// // Token tidak ditemukan di database = sudah dihapus (force logout)
// if (!dbSession) {
// console.log('⚠️ Token valid tapi sudah dihapus dari database (force logout)');
// return null;
// }
// // Step 3: Return data user terbaru dari database
// // Ini penting agar roleId selalu update
// return {
// id: dbSession.User.id,
// username: dbSession.User.username,
// nomor: dbSession.User.nomor,
// roleId: dbSession.User.roleId,
// isActive: dbSession.User.isActive,
// };
// } catch (dbError) {
// console.error("⚠️ Error cek database session:", dbError);
// // Fallback: jika database error, tetap pakai JWT
// return jwtUser;
// }
// } catch (error) {
// console.warn('❌ Session verification failed:', error);
// return null;
// }
// }
// src/app/api/auth/_lib/session_verify.ts
import { cookies } from 'next/headers';
import { decrypt } from './decrypt';
import prisma from '@/lib/prisma';
// app/api/auth/_lib/session_verify.ts
import { cookies } from "next/headers";
import { decrypt } from "./decrypt";
import prisma from "@/lib/prisma";
export async function verifySession() {
try {
const sessionKey = process.env.BASE_SESSION_KEY;
const jwtSecret = process.env.BASE_TOKEN_KEY;
if (!sessionKey || !jwtSecret) {
throw new Error('Environment variables tidak lengkap');
}
if (!sessionKey || !jwtSecret) throw new Error('Env tidak lengkap');
const token = (await cookies()).get(sessionKey)?.value;
if (!token) return null;
// Decrypt JWT
const jwtUser = await decrypt({ token, jwtSecret });
if (!jwtUser || !jwtUser.id) return null;
if (!jwtUser?.id) return null;
// ✅ Cek apakah session di-invalidate
const user = await prisma.user.findUnique({
where: { id: jwtUser.id as string },
select: {
id: true,
username: true,
nomor: true,
roleId: true,
isActive: true,
sessionInvalid: true, // ← Tambahkan field ini
// Cari session di DB berdasarkan token
const dbSession = await prisma.userSession.findFirst({
where: {
token,
active: true,
expiresAt: { gte: new Date() }
},
include: { user: true }
});
if (!user || user.sessionInvalid) {
console.log('⚠️ Session tidak valid (force logout)');
if (!dbSession) {
console.log('⚠️ Session tidak ditemukan di DB');
return null;
}
return user;
// Don't check isActive here, let the frontend handle it
return dbSession.user;
} catch (error) {
console.warn('Session verification failed:', error);
return null;

View File

@@ -0,0 +1,19 @@
// app/api/auth/clear-flow/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST() {
try {
// ✅ Next.js 15 syntax
const cookieStore = await cookies();
cookieStore.delete('auth_flow');
return NextResponse.json({ success: true });
} catch (error) {
console.error('❌ Error clearing flow cookie:', error);
return NextResponse.json(
{ success: false, message: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,97 +1,146 @@
// app/api/auth/finalize-registration/route.ts
// src/app/api/auth/finalize-registration/route.ts
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { sessionCreate } from "../_lib/session_create";
// ✅ Gunakan STRING untuk roleId
const DEFAULT_MENUS_BY_ROLE: Record<string, string[]> = {
"0": [
"Landing Page", "PPID", "Desa", "Kesehatan", "Keamanan",
"Ekonomi", "Inovasi", "Lingkungan", "Pendidikan", "User & Role"
],
"1": [
"Landing Page", "PPID", "Desa", "Keamanan",
"Ekonomi", "Inovasi", "Lingkungan", "User & Role"
],
"2": ["Landing Page", "Desa", "Ekonomi", "Inovasi", "Lingkungan"],
"3": ["Kesehatan"],
"4": ["Pendidikan"],
};
export async function POST(req: Request) {
try {
const { nomor, username, kodeId, roleId } = await req.json();
const { nomor, username, kodeId } = await req.json();
const cleanNomor = nomor.replace(/\D/g, "");
// Validasi input
if (!nomor || !username || !kodeId) {
if (!cleanNomor || !username || !kodeId) {
return NextResponse.json(
{ success: false, message: "Data tidak lengkap" },
{ status: 400 }
);
}
// Verifikasi OTP
const otpRecord = await prisma.kodeOtp.findUnique({
// Verify OTP
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { nomor: true, isActive: true }
});
if (!otpRecord?.isActive || otpRecord.nomor !== nomor) {
if (!otpRecord?.isActive || otpRecord.nomor !== cleanNomor) {
return NextResponse.json(
{ success: false, message: "OTP tidak valid" },
{ status: 400 }
);
}
// Cek apakah username sudah dipakai
const existingUser = await prisma.user.findUnique({
where: { username },
});
if (existingUser) {
// Check duplicate username
if (await prisma.user.findFirst({ where: { username } })) {
return NextResponse.json(
{ success: false, message: "Username sudah digunakan" },
{ status: 409 }
);
}
// Check duplicate nomor
if (await prisma.user.findUnique({ where: { nomor: cleanNomor } })) {
return NextResponse.json(
{ success: false, message: "Nomor sudah terdaftar" },
{ status: 409 }
);
}
// 🔥 Tentukan roleId sebagai STRING
const targetRoleId = "2"; // ✅ Default ADMIN_DESA (roleId "2")
// Validasi role exists
const roleExists = await prisma.role.findUnique({
where: { id: targetRoleId },
select: { id: true }
});
if (!roleExists) {
return NextResponse.json(
{ success: false, message: "Role tidak valid" },
{ status: 400 }
);
}
// Buat user baru
// ✅ Create user (inactive, waiting approval)
const newUser = await prisma.user.create({
data: {
username,
nomor,
roleId: roleId || "1", // Default role
isActive: false, // Menunggu approval
nomor: cleanNomor,
roleId: targetRoleId,
isActive: false, // Waiting for admin approval
},
});
// Nonaktifkan OTP
// ✅ Berikan akses menu default based on role
const menuIds = DEFAULT_MENUS_BY_ROLE[targetRoleId] || [];
if (menuIds.length > 0) {
await prisma.userMenuAccess.createMany({
data: menuIds.map(menuId => ({
userId: newUser.id,
menuId,
})),
skipDuplicates: true, // ✅ Avoid duplicate errors
});
}
// ✅ Mark OTP as used
await prisma.kodeOtp.update({
where: { id: kodeId },
data: { isActive: false },
});
// ✅ CREATE SESSION (JWT + Database)
try {
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: newUser.id,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId,
isActive: false, // User baru belum aktif
},
});
} catch (sessionError) {
console.error("❌ Error creating session:", sessionError);
return NextResponse.json(
{ success: false, message: "Gagal membuat session" },
{ status: 500 }
);
}
return NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
// ✅ Create session token
const token = await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
exp: "30 day",
user: {
id: newUser.id,
name: newUser.username,
nomor: newUser.nomor,
username: newUser.username,
roleId: newUser.roleId,
isActive: false,
isActive: false, // User belum aktif
},
invalidatePrevious: false,
});
// ✅ PENTING: Return JSON response (bukan redirect)
const response = NextResponse.json({
success: true,
message: "Registrasi berhasil. Menunggu persetujuan admin.",
userId: newUser.id,
});
// ✅ Set session cookie
const cookieName = process.env.BASE_SESSION_KEY || 'session';
response.cookies.set(cookieName, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: 'lax',
path: "/",
maxAge: 30 * 24 * 60 * 60, // 30 days
});
return response;
} catch (error) {
console.error("❌ Finalize Registration Error:", error);
return NextResponse.json(
{ success: false, message: "Registrasi gagal" },
{ success: false, message: "Registrasi gagal. Silakan coba lagi." },
{ status: 500 }
);
} finally {

View File

@@ -0,0 +1,22 @@
// app/api/auth/get-flow/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function GET() {
try {
// ✅ Next.js 15 syntax
const cookieStore = await cookies();
const flow = cookieStore.get('auth_flow')?.value || 'login';
return NextResponse.json({
success: true,
flow
});
} catch (error) {
console.error('❌ Error getting flow cookie:', error);
return NextResponse.json(
{ success: false, flow: 'login' },
{ status: 500 }
);
}
}

View File

@@ -2,6 +2,7 @@
import prisma from "@/lib/prisma";
import { NextResponse } from "next/server";
import { randomOTP } from "../_lib/randomOTP";
import { cookies } from "next/headers";
export async function POST(req: Request) {
if (req.method !== "POST") {
@@ -29,19 +30,37 @@ export async function POST(req: Request) {
const isRegistered = !!existingUser;
if (isRegistered) {
// ✅ User terdaftar → kirim OTP
const codeOtp = randomOTP();
const otpNumber = Number(codeOtp);
const waMessage = `Website Desa Darmasaba - Kode verifikasi Anda: ${codeOtp}`;
const waUrl = `https://wa.wibudev.com/code?nom=${encodeURIComponent(nomor)}&text=${encodeURIComponent(waMessage)}`;
// ✅ PERBAIKAN: Gunakan format pesan yang lebih sederhana
// Hapus karakter khusus yang bisa bikin masalah
const waMessage = `Website Desa Darmasaba\nKode verifikasi Anda ${codeOtp}`;
// // ✅ OPSI 1: Tanpa encoding (coba dulu ini)
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${waMessage}`;
// ✅ OPSI 2: Dengan encoding (kalau opsi 1 gagal)
const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodeURIComponent(waMessage)}`;
// ✅ OPSI 3: Encoding manual untuk URL-safe (alternatif terakhir)
// const encodedMessage = waMessage.replace(/\n/g, '%0A').replace(/ /g, '%20');
// const waUrl = `https://wa.wibudev.com/code?nom=${nomor}&text=${encodedMessage}`;
console.log("🔍 Debug WA URL:", waUrl); // Untuk debugging
const res = await fetch(waUrl);
const sendWa = await res.json();
console.log("📱 WA Response:", sendWa); // Debug response
if (sendWa.status !== "success") {
return NextResponse.json(
{ success: false, message: "Gagal mengirim OTP via WhatsApp" },
{
success: false,
message: "Gagal mengirim OTP via WhatsApp",
debug: sendWa // Tampilkan error detail
},
{ status: 400 }
);
}
@@ -50,6 +69,15 @@ export async function POST(req: Request) {
data: { nomor, otp: otpNumber, isActive: true },
});
const cookieStore = await cookies();
cookieStore.set('auth_flow', 'login', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 5, // 5 menit
path: '/'
});
return NextResponse.json({
success: true,
message: "Kode verifikasi dikirim",
@@ -57,16 +85,14 @@ export async function POST(req: Request) {
isRegistered: true,
});
} else {
// ❌ User belum terdaftar → JANGAN kirim OTP
return NextResponse.json({
success: true,
message: "Nomor belum terdaftar",
isRegistered: false,
// Tidak ada kodeId
});
}
} catch (error) {
console.error("Error Login:", error);
console.error("Error Login:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan saat login" },
{ status: 500 }

View File

@@ -5,36 +5,55 @@ import prisma from '@/lib/prisma';
export async function GET() {
try {
const user = await verifySession();
if (!user) {
const sessionUser = await verifySession();
if (!sessionUser) {
return NextResponse.json(
{ success: false, message: "Session tidak valid", user: null },
{ success: false, message: "Unauthorized", user: null },
{ status: 401 }
);
}
// ✅ Ambil menu akses kustom
const menuAccess = await prisma.userMenuAccess.findMany({
where: { userId: user.id },
select: { menuId: true },
});
const [dbUser, menuAccess] = await Promise.all([
prisma.user.findUnique({
where: { id: sessionUser.id },
select: {
id: true,
username: true,
nomor: true,
roleId: true, // STRING!
isActive: true, // BOOLEAN!
},
}),
prisma.userMenuAccess.findMany({
where: { userId: sessionUser.id },
select: { menuId: true },
}),
]);
if (!dbUser) {
return NextResponse.json(
{ success: false, message: "User not found", user: null },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
user: {
id: user.id,
name: user.username,
username: user.username,
nomor: user.nomor,
roleId: user.roleId,
isActive: user.isActive,
menuIds: menuAccess.map(m => m.menuId), // ✅ tambahkan ini
id: dbUser.id,
name: dbUser.username,
username: dbUser.username,
nomor: dbUser.nomor,
roleId: dbUser.roleId, // STRING!
isActive: dbUser.isActive, // BOOLEAN!
menuIds: menuAccess.map(m => m.menuId),
},
});
} catch (error) {
console.error("❌ Error in /api/auth/me:", error);
return NextResponse.json(
{ success: false, message: "Terjadi kesalahan", user: null },
{ success: false, message: "Internal server error", user: null },
{ status: 500 }
);
}

View File

@@ -2,9 +2,10 @@
import { NextResponse } from "next/server";
import prisma from "@/lib/prisma";
export async function POST(req: Request) {
export async function GET(request: Request) {
try {
const { kodeId } = await req.json();
const { searchParams } = new URL(request.url);
const kodeId = searchParams.get("kodeId");
if (!kodeId) {
return NextResponse.json(
@@ -15,7 +16,7 @@ export async function POST(req: Request) {
const otpRecord = await prisma.kodeOtp.findUnique({
where: { id: kodeId },
select: { id: true, nomor: true, isActive: true, createdAt: true },
select: { nomor: true, isActive: true },
});
if (!otpRecord || !otpRecord.isActive) {
@@ -27,12 +28,12 @@ export async function POST(req: Request) {
return NextResponse.json({
success: true,
data: otpRecord,
data: { nomor: otpRecord.nomor },
});
} catch (error) {
console.error("Error fetching OTP data:", error);
console.error("❌ Gagal mengambil data OTP:", error);
return NextResponse.json(
{ success: false, message: "Gagal mengambil data OTP" },
{ success: false, message: "Terjadi kesalahan internal" },
{ status: 500 }
);
} finally {

View File

@@ -0,0 +1,55 @@
import { NextResponse } from 'next/server';
import { verifySession } from '../_lib/session_verify';
import { sessionCreate } from '../_lib/session_create';
import prisma from '@/lib/prisma';
export async function POST() {
try {
const sessionUser = await verifySession();
if (!sessionUser) {
return NextResponse.json(
{ success: false, message: "Unauthorized" },
{ status: 401 }
);
}
// Get fresh user data
const user = await prisma.user.findUnique({
where: { id: sessionUser.id },
select: {
id: true,
username: true,
roleId: true,
isActive: true,
},
});
if (!user) {
return NextResponse.json(
{ success: false, message: "User not found" },
{ status: 404 }
);
}
// Create new session with updated data
await sessionCreate({
sessionKey: process.env.BASE_SESSION_KEY!,
jwtSecret: process.env.BASE_TOKEN_KEY!,
user: {
id: user.id,
username: user.username,
roleId: user.roleId,
isActive: user.isActive,
},
invalidatePrevious: false, // Keep existing sessions
});
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error refreshing session:', error);
return NextResponse.json(
{ success: false, message: "Internal server error" },
{ status: 500 }
);
}
}

Some files were not shown because too many files have changed in this diff Show More