Compare commits
55 Commits
nico/23-ok
...
nico/15-de
| Author | SHA1 | Date | |
|---|---|---|---|
| 342e9bbc65 | |||
| f6f77d9e35 | |||
| a00481152c | |||
| 242ea86f77 | |||
| 99c2c9c6d7 | |||
| ac2fc1a705 | |||
| 9dbe172165 | |||
| cc318d4d54 | |||
| dcb8017594 | |||
| ec3ad12531 | |||
| dad44c0537 | |||
| 867dce42f0 | |||
| 7bb17ddf22 | |||
| a4069d3cba | |||
| ffe5e6dd9f | |||
| dcf195f54f | |||
| c03a6b3aed | |||
| 1bb9f239db | |||
| a213ff7d37 | |||
| 0018bdc251 | |||
| 83fb39a957 | |||
| 7238692dd0 | |||
| 8b50139d79 | |||
| 066180fc0e | |||
| 67f29aabef | |||
| dbf7c34228 | |||
| 036fc86fed | |||
| 2cecec733e | |||
| c64a2e5457 | |||
| 757911d7dd | |||
| 54232e4465 | |||
| 29a9a59bca | |||
| 2fb3666e57 | |||
| e30b27f7a4 | |||
| e941ed3893 | |||
| ace5aff1b6 | |||
| 716db0adca | |||
| a291bdfb51 | |||
| 0dff8f3254 | |||
| 78b8aa74cd | |||
| a0537810e8 | |||
| b3c169a2d4 | |||
| 2608a5ffdd | |||
| 6c32f3ebdb | |||
| 0feeb4de93 | |||
| 9622eb5a9a | |||
| 417a8937f5 | |||
| db8909b9ed | |||
| f66a46f645 | |||
| fb57698dc9 | |||
| d128313e71 | |||
| 7b4bb1e58e | |||
| 0befe6a3f2 | |||
| a6663bbcee | |||
| ed371bd0d9 |
15
package.json
15
package.json
@@ -3,9 +3,9 @@
|
||||
"version": "0.1.5",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun --bun next dev",
|
||||
"build": "bun --bun next build",
|
||||
"start": "bun --bun next start"
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "bun run prisma/seed.ts"
|
||||
@@ -19,6 +19,7 @@
|
||||
"@elysiajs/static": "^1.3.0",
|
||||
"@elysiajs/stream": "^1.1.0",
|
||||
"@elysiajs/swagger": "^1.2.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@mantine/carousel": "^7.16.2",
|
||||
"@mantine/charts": "^7.17.1",
|
||||
"@mantine/core": "^7.17.4",
|
||||
@@ -26,6 +27,7 @@
|
||||
"@mantine/dropzone": "^8.1.1",
|
||||
"@mantine/form": "^8.1.0",
|
||||
"@mantine/hooks": "^7.17.4",
|
||||
"@mantine/modals": "^8.3.6",
|
||||
"@mantine/tiptap": "^7.17.4",
|
||||
"@paljs/types": "^8.1.0",
|
||||
"@prisma/client": "^6.3.1",
|
||||
@@ -52,11 +54,13 @@
|
||||
"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",
|
||||
"embla-carousel-autoplay": "^8.5.2",
|
||||
"embla-carousel-react": "^7.1.0",
|
||||
"embla-carousel": "^8.6.0",
|
||||
"embla-carousel-autoplay": "^8.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"extract-zip": "^2.0.1",
|
||||
"form-data": "^4.0.2",
|
||||
"framer-motion": "^12.23.5",
|
||||
@@ -80,6 +84,7 @@
|
||||
"prisma": "^6.3.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-exif-orientation-img": "^0.1.5",
|
||||
"react-international-phone": "^4.6.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
"react-simple-toasts": "^6.1.0",
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
'mantine-breakpoint-xs': '36em',
|
||||
'mantine-breakpoint-sm': '48em',
|
||||
'mantine-breakpoint-md': '62em',
|
||||
'mantine-breakpoint-lg': '75em',
|
||||
'mantine-breakpoint-xl': '88em',
|
||||
},
|
||||
plugins: {
|
||||
'postcss-preset-mantine': {},
|
||||
'postcss-simple-vars': {
|
||||
variables: {
|
||||
/* Mobile first */
|
||||
'mantine-breakpoint-xs': '30em', // 480px → mobile kecil–normal
|
||||
'mantine-breakpoint-sm': '48em', // 768px → tablet / mobile landscape
|
||||
'mantine-breakpoint-md': '64em', // 1024px → laptop & desktop kecil
|
||||
'mantine-breakpoint-lg': '80em', // 1280px → desktop standar
|
||||
'mantine-breakpoint-xl': '90em', // 1440px+ → desktop besar
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
[
|
||||
{
|
||||
"id": "role-1",
|
||||
"name": "ADMIN DESA",
|
||||
"description": "Administrator Desa",
|
||||
"permissions": ["manage_users", "manage_content", "view_reports"],
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "role-2",
|
||||
"name": "ADMIN KESEHATAN",
|
||||
"description": "Administrator Bidang Kesehatan",
|
||||
"permissions": ["manage_health_data", "view_reports"],
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "role-3",
|
||||
"name": "ADMIN SEKOLAH",
|
||||
"description": "Administrator Sekolah",
|
||||
"permissions": ["manage_school_data", "view_reports"],
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
{
|
||||
"id": "0",
|
||||
"name": "DEVELOPER",
|
||||
"description": "Developer",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "1",
|
||||
"name": "SUPER ADMIN",
|
||||
"description": "Administrator",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "ADMIN DESA",
|
||||
"description": "Administrator Desa",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "ADMIN KESEHATAN",
|
||||
"description": "Administrator Bidang Kesehatan",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "ADMIN PENDIDIKAN",
|
||||
"description": "Administrator Bidang Pendidikan",
|
||||
"isActive": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
[
|
||||
{
|
||||
"id": "user-1",
|
||||
"nama": "Admin Desa",
|
||||
"nomor": "089647037426",
|
||||
"roleId": "role-1",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "user-2",
|
||||
"nama": "Admin Kesehatan",
|
||||
"nomor": "082339004198",
|
||||
"roleId": "role-2",
|
||||
"isActive": true
|
||||
},
|
||||
{
|
||||
"id": "user-3",
|
||||
"nama": "Admin Sekolah",
|
||||
"nomor": "085237157222",
|
||||
"roleId": "role-3",
|
||||
"isActive": true
|
||||
"id": "cmie1o0zh0002vn132vtzg7hh",
|
||||
"username": "SuperAdmin-Nico",
|
||||
"nomor": "6289647037426",
|
||||
"roleId": 0,
|
||||
"isActive": true,
|
||||
"sessionInvalid": false
|
||||
}
|
||||
]
|
||||
|
||||
1127
prisma/migrations/20251119062255_add_unique_username/migration.sql
Normal file
1127
prisma/migrations/20251119062255_add_unique_username/migration.sql
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -184,18 +185,46 @@ model SdgsDesa {
|
||||
//========================================= APBDes ========================================= //
|
||||
model APBDes {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
jumlah String
|
||||
tahun Int?
|
||||
name String? // misalnya: "APBDes Tahun 2025"
|
||||
deskripsi String?
|
||||
jumlah String? // total keseluruhan (opsional, bisa juga dihitung dari items)
|
||||
items APBDesItem[]
|
||||
image FileStorage? @relation("APBDesImage", fields: [imageId], references: [id])
|
||||
imageId String?
|
||||
file FileStorage? @relation("APBDesFile", fields: [fileId], references: [id])
|
||||
fileId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
deletedAt DateTime? // opsional, tidak perlu default now()
|
||||
isActive Boolean @default(true)
|
||||
}
|
||||
|
||||
model APBDesItem {
|
||||
id String @id @default(cuid())
|
||||
kode String // contoh: "4", "4.1", "4.1.2"
|
||||
uraian String // nama item, contoh: "Pendapatan Asli Desa", "Hasil Usaha"
|
||||
anggaran Float // dalam satuan Rupiah (bisa DECIMAL di DB, tapi Float umum di TS/JS)
|
||||
realisasi Float
|
||||
selisih Float // realisasi - anggaran
|
||||
persentase Float
|
||||
tipe String? // (realisasi / anggaran) * 100
|
||||
level Int // 1 = kelompok utama, 2 = sub-kelompok, 3 = detail
|
||||
parentId String? // untuk relasi hierarki
|
||||
parent APBDesItem? @relation("APBDesItemParent", fields: [parentId], references: [id])
|
||||
children APBDesItem[] @relation("APBDesItemParent")
|
||||
apbdesId String
|
||||
apbdes APBDes @relation(fields: [apbdesId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
isActive Boolean @default(true)
|
||||
|
||||
@@index([kode])
|
||||
@@index([level])
|
||||
@@index([apbdesId])
|
||||
}
|
||||
|
||||
//========================================= PRESTASI DESA ========================================= //
|
||||
model PrestasiDesa {
|
||||
id String @id @default(cuid())
|
||||
@@ -754,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 {
|
||||
@@ -797,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 {
|
||||
@@ -836,7 +868,7 @@ model TarifDanLayanan {
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime @default(now())
|
||||
isActive Boolean @default(true)
|
||||
FasilitasKesehatan FasilitasKesehatan[]
|
||||
FasilitasKesehatan FasilitasKesehatan[] @relation("Tarif")
|
||||
}
|
||||
|
||||
// ========================================= JADWAL KEGIATAN ========================================= //
|
||||
@@ -1942,23 +1974,28 @@ model KeunggulanProgram {
|
||||
}
|
||||
|
||||
model BeasiswaPendaftar {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
namaLengkap String
|
||||
nik String @unique
|
||||
nis String?
|
||||
kelas String?
|
||||
jenisKelamin JenisKelamin
|
||||
alamatDomisili String?
|
||||
tempatLahir String
|
||||
tanggalLahir DateTime
|
||||
jenisKelamin JenisKelamin
|
||||
kewarganegaraan String
|
||||
agama Agama
|
||||
alamatKTP String
|
||||
alamatDomisili String?
|
||||
namaOrtu String?
|
||||
nik String @unique
|
||||
pekerjaanOrtu String?
|
||||
penghasilan String?
|
||||
noHp String
|
||||
email String @unique
|
||||
statusPernikahan StatusPernikahan
|
||||
kewarganegaraan String?
|
||||
agama Agama?
|
||||
alamatKTP String?
|
||||
email String? @unique
|
||||
statusPernikahan StatusPernikahan?
|
||||
ukuranBaju UkuranBaju?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
enum JenisKelamin {
|
||||
@@ -2130,25 +2167,28 @@ enum StatusPeminjaman {
|
||||
// ========================================= USER ========================================= //
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
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)
|
||||
lastLogin DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
deletedAt DateTime?
|
||||
id String @id @default(cuid())
|
||||
username String
|
||||
nomor String @unique
|
||||
roleId String @default("2")
|
||||
isActive Boolean @default(false)
|
||||
sessionInvalid Boolean @default(false)
|
||||
lastLogin DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
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 // Menyimpan permission dalam format JSON
|
||||
permissions Json?
|
||||
isActive Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -2167,26 +2207,32 @@ model KodeOtp {
|
||||
otp Int
|
||||
}
|
||||
|
||||
// Tabel untuk menyimpan permission
|
||||
model Permission {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
model UserSession {
|
||||
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
|
||||
|
||||
@@map("permissions")
|
||||
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 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
|
||||
model UserMenuAccess {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
menuId String // ID menu (misal: "Landing Page", "Kesehatan")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([userId, menuId]) // Satu user tidak bisa punya akses menu yang sama dua kali
|
||||
}
|
||||
|
||||
// ========================================= DATA PENDIDIKAN ========================================= //
|
||||
|
||||
180
prisma/seed.ts
180
prisma/seed.ts
@@ -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";
|
||||
@@ -54,73 +55,110 @@ import tujuanProgram2 from "./data/pendidikan/pendidikan-non-formal/tujuan-progr
|
||||
import programUnggulan from "./data/pendidikan/program-pendidikan-anak/program-unggulan.json";
|
||||
import tujuanProgram from "./data/pendidikan/program-pendidikan-anak/tujuan-program.json";
|
||||
import roles from "./data/user/roles.json";
|
||||
import users from "./data/user/users.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 () => {
|
||||
// =========== USER & 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,
|
||||
permissions: r.permissions,
|
||||
isActive: r.isActive,
|
||||
});
|
||||
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");
|
||||
|
||||
console.log("✅ Roles seeded");
|
||||
|
||||
// =========== USERS ===========
|
||||
// =========== USER ===========
|
||||
console.log("🔄 Seeding users...");
|
||||
for (const u of users) {
|
||||
// First verify the role exists
|
||||
const roleExists = await prisma.role.findUnique({
|
||||
where: { id: u.roleId },
|
||||
});
|
||||
|
||||
if (!roleExists) {
|
||||
console.error(`❌ Role with id ${u.roleId} not found for user ${u.nama}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await safeSeedUnique("user", { id: u.id }, {
|
||||
username: u.nama,
|
||||
nomor: u.nomor,
|
||||
roleId: u.roleId,
|
||||
isActive: u.isActive,
|
||||
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 seeded");
|
||||
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 ===========
|
||||
@@ -539,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 ===========
|
||||
|
||||
@@ -811,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})`);
|
||||
@@ -891,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) {
|
||||
@@ -1183,7 +1248,6 @@ import { safeSeedUnique } from "./safeseedUnique";
|
||||
|
||||
// seed assets
|
||||
await seedAssets();
|
||||
|
||||
})()
|
||||
.then(() => prisma.$disconnect())
|
||||
.catch((e) => {
|
||||
|
||||
BIN
public/mangupuraaward.jpeg
Normal file
BIN
public/mangupuraaward.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 177 KiB |
@@ -7,6 +7,7 @@ import Underline from '@tiptap/extension-underline';
|
||||
import TextAlign from '@tiptap/extension-text-align';
|
||||
import Superscript from '@tiptap/extension-superscript';
|
||||
import SubScript from '@tiptap/extension-subscript';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type CreateEditorProps = {
|
||||
value: string;
|
||||
@@ -32,6 +33,13 @@ export default function CreateEditor({ value, onChange }: CreateEditorProps) {
|
||||
},
|
||||
});
|
||||
|
||||
// 👇 Tambahkan efek untuk sinkronisasi value dari luar (resetForm)
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value || '');
|
||||
}
|
||||
}, [value, editor]);
|
||||
|
||||
return (
|
||||
<RichTextEditor editor={editor}>
|
||||
<RichTextEditor.Toolbar sticky stickyOffset="var(--docs-header-height)">
|
||||
|
||||
@@ -47,6 +47,7 @@ export default function EditEditor({ value, onChange }: EditEditorProps) {
|
||||
editor.off('update', updateHandler);
|
||||
};
|
||||
}, [editor, onChange]);
|
||||
|
||||
|
||||
return (
|
||||
<RichTextEditor editor={editor}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import { Box, rem, Select } from '@mantine/core';
|
||||
import { Box, Group, rem, Select, SelectProps } from '@mantine/core';
|
||||
import {
|
||||
IconAmbulance,
|
||||
IconCash,
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
IconTrophy,
|
||||
IconTruckFilled,
|
||||
IconBuilding,
|
||||
IconAlertTriangle
|
||||
IconAlertTriangle,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
const iconMap = {
|
||||
@@ -38,26 +38,26 @@ const iconMap = {
|
||||
scale: { label: 'Scale', icon: IconScale },
|
||||
clipboard: { label: 'Clipboard', icon: IconClipboardTextFilled },
|
||||
trash: { label: 'Trash', icon: IconTrashFilled },
|
||||
lingkunganSehat: {label: 'Lingkungan Sehat', icon: IconHomeEco},
|
||||
sumberOksigen: {label: 'Sumber Oksigen', icon: IconChristmasTreeFilled},
|
||||
ekonomiBerkelanjutan: {label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp},
|
||||
mencegahBencana: {label: 'Mencegah Bencana', icon: IconShieldFilled},
|
||||
rumah: {label: 'Rumah', icon: IconHome},
|
||||
pohon: {label: 'Pohon', icon: IconTree},
|
||||
air: {label: 'Air', icon: IconDroplet},
|
||||
bantuan: {label: 'Bantuan', icon: IconCash},
|
||||
pelatihan: {label: 'Pelatihan', icon: IconSchool},
|
||||
subsidi: {label: 'Subsidi', icon: IconShoppingCart},
|
||||
layananKesehatan: {label: 'Layanan Kesehatan', icon: IconHospital},
|
||||
polisi: {label: 'Polisi', icon: IconShieldFilled},
|
||||
ambulans: {label: 'Ambulans', icon: IconAmbulance},
|
||||
pemadam: {label: 'Pemadam', icon: IconFiretruck},
|
||||
rumahSakit: {label: 'Rumah Sakit', icon: IconHospital},
|
||||
bangunan: {label: 'Bangunan', icon: IconBuilding},
|
||||
darurat: {label: 'Darurat', icon: IconAlertTriangle},
|
||||
lingkunganSehat: { label: 'Lingkungan Sehat', icon: IconHomeEco },
|
||||
sumberOksigen: { label: 'Sumber Oksigen', icon: IconChristmasTreeFilled },
|
||||
ekonomiBerkelanjutan: { label: 'Ekonomi Berkelanjutan', icon: IconTrendingUp },
|
||||
mencegahBencana: { label: 'Mencegah Bencana', icon: IconShieldFilled },
|
||||
rumah: { label: 'Rumah', icon: IconHome },
|
||||
pohon: { label: 'Pohon', icon: IconTree },
|
||||
air: { label: 'Air', icon: IconDroplet },
|
||||
bantuan: { label: 'Bantuan', icon: IconCash },
|
||||
pelatihan: { label: 'Pelatihan', icon: IconSchool },
|
||||
subsidi: { label: 'Subsidi', icon: IconShoppingCart },
|
||||
layananKesehatan: { label: 'Layanan Kesehatan', icon: IconHospital },
|
||||
polisi: { label: 'Polisi', icon: IconShieldFilled },
|
||||
ambulans: { label: 'Ambulans', icon: IconAmbulance },
|
||||
pemadam: { label: 'Pemadam', icon: IconFiretruck },
|
||||
rumahSakit: { label: 'Rumah Sakit', icon: IconHospital },
|
||||
bangunan: { label: 'Bangunan', icon: IconBuilding },
|
||||
darurat: { label: 'Darurat', icon: IconAlertTriangle },
|
||||
};
|
||||
|
||||
type IconKey = keyof typeof iconMap;
|
||||
export type IconKey = keyof typeof iconMap;
|
||||
|
||||
const iconList = Object.entries(iconMap).map(([value, data]) => ({
|
||||
value,
|
||||
@@ -67,44 +67,52 @@ const iconList = Object.entries(iconMap).map(([value, data]) => ({
|
||||
export default function SelectIconProgramEdit({
|
||||
onChange,
|
||||
value,
|
||||
...props
|
||||
}: {
|
||||
onChange: (value: IconKey) => void;
|
||||
value: IconKey;
|
||||
}) {
|
||||
const IconComponent = iconMap[value]?.icon || null;
|
||||
|
||||
onChange: (value: IconKey | '') => void;
|
||||
value: IconKey | '';
|
||||
} & Omit<SelectProps, 'onChange' | 'value' | 'data'>) {
|
||||
return (
|
||||
<Box maw={300}>
|
||||
<Select
|
||||
placeholder="Pilih ikon"
|
||||
value={value}
|
||||
onChange={(value) => {
|
||||
if (value) onChange(value as IconKey);
|
||||
value={value || ''}
|
||||
onChange={(val: string | null) => {
|
||||
if (val) {
|
||||
onChange(val as IconKey);
|
||||
} else {
|
||||
onChange('');
|
||||
}
|
||||
}}
|
||||
data={iconList}
|
||||
renderOption={({ option }) => {
|
||||
const Icon = iconMap[option.value as IconKey]?.icon;
|
||||
return (
|
||||
<Group gap="sm">
|
||||
{Icon && <Icon size={18} stroke={1.5} />}
|
||||
{option.label}
|
||||
</Group>
|
||||
);
|
||||
}}
|
||||
leftSection={
|
||||
IconComponent && (
|
||||
<Box>
|
||||
<IconComponent size={24} stroke={1.5} />
|
||||
value && iconMap[value as IconKey] ? (
|
||||
<Box ml={-4}>
|
||||
{(() => {
|
||||
const Icon = iconMap[value as IconKey].icon;
|
||||
return <Icon size={20} stroke={1.5} />;
|
||||
})()}
|
||||
</Box>
|
||||
)
|
||||
) : null
|
||||
}
|
||||
withCheckIcon={false}
|
||||
searchable={false}
|
||||
rightSectionWidth={0}
|
||||
searchable
|
||||
styles={{
|
||||
input: {
|
||||
textAlign: 'left',
|
||||
fontSize: rem(16),
|
||||
paddingLeft: 40,
|
||||
},
|
||||
section: {
|
||||
left: 10,
|
||||
right: 'auto',
|
||||
fontSize: rem(16),
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
76
src/app/admin/(dashboard)/_com/selectSocialMedia.tsx
Normal file
76
src/app/admin/(dashboard)/_com/selectSocialMedia.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
src/app/admin/(dashboard)/_com/selectSocialMediaEdit.tsx
Normal file
56
src/app/admin/(dashboard)/_com/selectSocialMediaEdit.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const penghargaanState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
penghargaanState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -287,7 +287,7 @@ const pengumuman = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
pengumuman.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -101,6 +101,38 @@ const ApbDesa = proxy({
|
||||
}
|
||||
},
|
||||
},
|
||||
findFirst: {
|
||||
data: null as Prisma.ApbDesaGetPayload<{
|
||||
include: { pendapatan: true; belanja: true; pembiayaan: true };
|
||||
}> | null,
|
||||
loading: false,
|
||||
async load(params?: Record<string, any>) {
|
||||
try {
|
||||
this.loading = true;
|
||||
|
||||
// ✅ request ke endpoint find-first
|
||||
const res = await ApiFetch.api.ekonomi.pendapatanaslidesa.apbdesa[
|
||||
"find-first"
|
||||
].get({ query: params || {} });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
this.data = res.data.data ?? null;
|
||||
} else {
|
||||
this.data = null;
|
||||
toast.error(res.data?.message || "Gagal memuat data pertama APB Desa");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error findFirst APB Desa:", error);
|
||||
toast.error("Gagal memuat data APB Desa pertama");
|
||||
this.data = null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.data = null;
|
||||
},
|
||||
},
|
||||
update: {
|
||||
id: "",
|
||||
form: { ...ApbDesaDefaultForm },
|
||||
|
||||
@@ -49,7 +49,7 @@ const demografiPekerjaan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
demografiPekerjaan.create.form = { ...defaultForm };
|
||||
demografiPekerjaan.findMany.load();
|
||||
return id;
|
||||
|
||||
@@ -47,7 +47,7 @@ const jumlahPendudukMiskin = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
jumlahPendudukMiskin.create.form = {
|
||||
year: 0,
|
||||
totalPoorPopulation: 0,
|
||||
|
||||
@@ -89,7 +89,7 @@ const jumlahPengangguran = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
jumlahPengangguran.create.form = { ...jumlahPengangguranForm };
|
||||
jumlahPengangguran.findMany.load();
|
||||
return id;
|
||||
|
||||
@@ -47,7 +47,7 @@ const lowonganKerjaState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
lowonganKerjaState.create.loading = false;
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -45,7 +45,7 @@ const programKemiskinanState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
programKemiskinanState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -46,7 +46,7 @@ const grafikSektorUnggulan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikSektorUnggulan.create.form = {
|
||||
name: "",
|
||||
description: "",
|
||||
|
||||
@@ -51,7 +51,7 @@ const grafikBerdasarkanUsiaKerjaNganggur = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikBerdasarkanUsiaKerjaNganggur.create.form = {
|
||||
usia18_25: "",
|
||||
usia26_35: "",
|
||||
@@ -255,7 +255,7 @@ const grafikBerdasarkanPendidikan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikBerdasarkanPendidikan.create.form = {
|
||||
SD: "",
|
||||
SMP: "",
|
||||
|
||||
@@ -37,7 +37,7 @@ const desaDigitalState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
desaDigitalState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -37,7 +37,7 @@ const infoTeknoState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
infoTeknoState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -6,9 +6,9 @@ import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(1, "Nama minimal 1 karakter"),
|
||||
deskripsi: z.string().min(1, "Deskripsi minimal 1 karakter"),
|
||||
slug: z.string().min(1, "Deskripsi singkat minimal 1 karakter"),
|
||||
name: z.string().min(5, "Nama minimal 5 karakter"),
|
||||
deskripsi: z.string().min(5, "Deskripsi minimal 5 karakter"),
|
||||
slug: z.string().min(5, "Deskripsi singkat minimal 5 karakter"),
|
||||
icon: z.string().min(1, "Icon minimal 1 karakter"),
|
||||
});
|
||||
|
||||
@@ -29,26 +29,33 @@ const programKreatifState = proxy({
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
toast.error(err);
|
||||
return false; // ⬅️ ini penting
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
programKreatifState.create.loading = true;
|
||||
const res = await ApiFetch.api.inovasi.programkreatif["create"].post(
|
||||
programKreatifState.create.form
|
||||
);
|
||||
|
||||
if (res.status === 200) {
|
||||
programKreatifState.findMany.load();
|
||||
return toast.success("success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
return true;
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
toast.error("failed create");
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log((error as Error).message);
|
||||
console.error((error as Error).message);
|
||||
toast.error("Terjadi kesalahan saat create");
|
||||
return false;
|
||||
} finally {
|
||||
programKreatifState.create.loading = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
},
|
||||
findMany: {
|
||||
data: null as any[] | null,
|
||||
|
||||
@@ -37,7 +37,7 @@ const keamananLingkunganState = proxy({
|
||||
].post(keamananLingkunganState.create.form);
|
||||
if (res.status === 200) {
|
||||
keamananLingkunganState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -38,7 +38,7 @@ const kontakDaruratKeamananState = proxy({
|
||||
].post(kontakDaruratKeamananState.create.form);
|
||||
if (res.status === 200) {
|
||||
kontakDaruratKeamananState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
@@ -294,7 +294,7 @@ const kontakDaruratItem = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
kontakDaruratItem.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -88,7 +88,7 @@ const laporanPublikState = proxy({
|
||||
|
||||
if (res.status === 200) {
|
||||
laporanPublikState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
|
||||
console.log(res);
|
||||
|
||||
@@ -40,7 +40,7 @@ const pencegahanKriminalitasState = proxy({
|
||||
].post(pencegahanKriminalitasState.create.form);
|
||||
if (res.status === 200) {
|
||||
pencegahanKriminalitasState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -37,7 +37,7 @@ const tipsKeamananState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
tipsKeamananState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -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({
|
||||
@@ -351,7 +365,7 @@ const dokter = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
dokter.create.create.form = { ...defaultDokterForm };
|
||||
dokter.findMany.load();
|
||||
return id;
|
||||
@@ -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;
|
||||
|
||||
@@ -43,7 +43,7 @@ const grafikkepuasan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikkepuasan.create.form = { ...defaultForm };
|
||||
grafikkepuasan.findMany.load();
|
||||
return id;
|
||||
|
||||
@@ -50,7 +50,7 @@ const persentasekelahiran = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
persentasekelahiran.create.form = { ...defaultForm };
|
||||
persentasekelahiran.findMany.load();
|
||||
return id;
|
||||
|
||||
@@ -5,58 +5,117 @@ import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
const templateapbDesaForm = z.object({
|
||||
name: z.string().min(1, "Judul minimal 1 karakter"),
|
||||
jumlah: z.string().min(1, "Deskripsi minimal 1 karakter"),
|
||||
imageId: z.string().min(1, "File minimal 1"),
|
||||
fileId: z.string().min(1, "File minimal 1"),
|
||||
// --- Zod Schema ---
|
||||
const ApbdesItemSchema = z.object({
|
||||
kode: z.string().min(1, "Kode wajib diisi"),
|
||||
uraian: z.string().min(1, "Uraian wajib diisi"),
|
||||
anggaran: z.number().min(0),
|
||||
realisasi: z.number().min(0),
|
||||
selisih: z.number(),
|
||||
persentase: z.number(),
|
||||
level: z.number().int().min(1).max(3),
|
||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||
});
|
||||
|
||||
const defaultapbdesForm = {
|
||||
name: "",
|
||||
jumlah: "",
|
||||
const ApbdesFormSchema = z.object({
|
||||
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
||||
imageId: z.string().min(1, "Gambar wajib diunggah"),
|
||||
fileId: z.string().min(1, "File wajib diunggah"),
|
||||
items: z.array(ApbdesItemSchema).min(1, "Minimal ada 1 item"),
|
||||
});
|
||||
|
||||
// --- Default Form ---
|
||||
const defaultApbdesForm = {
|
||||
tahun: new Date().getFullYear(),
|
||||
imageId: "",
|
||||
fileId: "",
|
||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||
};
|
||||
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
function normalizeItem(item: Partial<z.infer<typeof ApbdesItemSchema>>): z.infer<typeof ApbdesItemSchema> {
|
||||
const anggaran = item.anggaran ?? 0;
|
||||
const realisasi = item.realisasi ?? 0;
|
||||
|
||||
|
||||
|
||||
|
||||
// ✅ Formula yang benar
|
||||
const selisih = anggaran - realisasi; // positif = sisa anggaran, negatif = over budget
|
||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0; // persentase realisasi terhadap anggaran
|
||||
|
||||
return {
|
||||
kode: item.kode || "",
|
||||
uraian: item.uraian || "",
|
||||
anggaran,
|
||||
realisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
level: item.level || 1,
|
||||
tipe: item.tipe, // biarkan null jika memang null
|
||||
};
|
||||
}
|
||||
|
||||
// --- State Utama ---
|
||||
const apbdes = proxy({
|
||||
create: {
|
||||
form: { ...defaultapbdesForm },
|
||||
form: { ...defaultApbdesForm },
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateapbDesaForm.safeParse(apbdes.create.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
}
|
||||
try {
|
||||
apbdes.create.loading = true;
|
||||
const res = await ApiFetch.api.landingpage.apbdes["create"].post({
|
||||
...apbdes.create.form,
|
||||
});
|
||||
|
||||
if (res.status === 200) {
|
||||
addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
|
||||
const normalized = normalizeItem(item);
|
||||
this.form.items.push(normalized);
|
||||
},
|
||||
|
||||
removeItem(index: number) {
|
||||
this.form.items.splice(index, 1);
|
||||
},
|
||||
|
||||
updateItem(index: number, updates: Partial<z.infer<typeof ApbdesItemSchema>>) {
|
||||
const current = this.form.items[index];
|
||||
if (current) {
|
||||
const updated = normalizeItem({ ...current, ...updates });
|
||||
this.form.items[index] = updated;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.form = { ...defaultApbdesForm };
|
||||
},
|
||||
|
||||
async create() {
|
||||
const parsed = ApbdesFormSchema.safeParse(this.form);
|
||||
if (!parsed.success) {
|
||||
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
|
||||
toast.error(`Validasi gagal:\n${errors.join("\n")}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.loading = true;
|
||||
const res = await ApiFetch.api.landingpage.apbdes["create"].post(parsed.data);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("APBDes berhasil dibuat");
|
||||
apbdes.findMany.load();
|
||||
return toast.success("Data berhasil ditambahkan");
|
||||
this.reset();
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal membuat APBDes");
|
||||
}
|
||||
return toast.error("Gagal menambahkan data");
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("Gagal menambahkan data");
|
||||
} catch (error: any) {
|
||||
console.error("Create APBDes error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat membuat APBDes");
|
||||
} finally {
|
||||
apbdes.create.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
file: true;
|
||||
};
|
||||
include: { image: true; file: true; items: true };
|
||||
}>[]
|
||||
| null,
|
||||
page: 1,
|
||||
@@ -64,194 +123,202 @@ const apbdes = proxy({
|
||||
total: 0,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "") => { // Change to arrow function
|
||||
apbdes.findMany.loading = true; // Use the full path to access the property
|
||||
|
||||
load: async (page = 1, limit = 10, search = "") => {
|
||||
apbdes.findMany.loading = true;
|
||||
apbdes.findMany.page = page;
|
||||
apbdes.findMany.search = search;
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
const query: Record<string, string> = { page: String(page), limit: String(limit) };
|
||||
if (search) query.search = search;
|
||||
|
||||
const res = await ApiFetch.api.landingpage.apbdes[
|
||||
"findMany"
|
||||
].get({
|
||||
query
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
|
||||
const res = await ApiFetch.api.landingpage.apbdes["findMany"].get({ query });
|
||||
|
||||
if (res.data?.success) {
|
||||
apbdes.findMany.data = res.data.data || [];
|
||||
apbdes.findMany.total = res.data.total || 0;
|
||||
apbdes.findMany.totalPages = res.data.totalPages || 1;
|
||||
apbdes.findMany.total = res.data.meta?.total || 0;
|
||||
apbdes.findMany.totalPages = res.data.meta?.totalPages || 1;
|
||||
} else {
|
||||
console.error("Failed to load pegawai:", res.data?.message);
|
||||
apbdes.findMany.data = [];
|
||||
apbdes.findMany.total = 0;
|
||||
apbdes.findMany.totalPages = 1;
|
||||
toast.error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading pegawai:", error);
|
||||
console.error("FindMany error:", error);
|
||||
apbdes.findMany.data = [];
|
||||
apbdes.findMany.total = 0;
|
||||
apbdes.findMany.totalPages = 1;
|
||||
toast.error("Gagal memuat daftar APBDes");
|
||||
} finally {
|
||||
apbdes.findMany.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
findUnique: {
|
||||
data: null as Prisma.APBDesGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
file: true;
|
||||
};
|
||||
}> | null,
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: { image: true; file: true; items: true };
|
||||
}>
|
||||
| null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id || id.trim() === '') {
|
||||
this.data = null;
|
||||
this.error = "ID tidak valid";
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/landingpage/apbdes/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
apbdes.findUnique.data = data.data ?? null;
|
||||
// Pastikan URL-nya benar
|
||||
const url = `/api/landingpage/apbdes/${id}`;
|
||||
console.log("🌐 Fetching:", url);
|
||||
|
||||
// Gunakan fetch biasa atau ApiFetch dengan cara yang benar
|
||||
const response = await fetch(url);
|
||||
const res = await response.json();
|
||||
|
||||
console.log("📦 Response:", res);
|
||||
|
||||
if (res.success && res.data) {
|
||||
this.data = res.data;
|
||||
} else {
|
||||
console.error("Failed to fetch data", res.status, res.statusText);
|
||||
apbdes.findUnique.data = null;
|
||||
this.data = null;
|
||||
this.error = res.message || "Gagal memuat detail APBDes";
|
||||
toast.error(this.error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching data:", error);
|
||||
apbdes.findUnique.data = null;
|
||||
console.error("❌ FindUnique error:", error);
|
||||
this.data = null;
|
||||
this.error = "Gagal memuat detail APBDes";
|
||||
toast.error(this.error);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
try {
|
||||
apbdes.delete.loading = true;
|
||||
this.loading = true;
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)["del"][id].delete();
|
||||
|
||||
const response = await fetch(`/api/landingpage/apbdes/del/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result?.success) {
|
||||
toast.success(result.message || "apbdes berhasil dihapus");
|
||||
await apbdes.findMany.load(); // refresh list
|
||||
if (res.data?.success) {
|
||||
toast.success("APBDes berhasil dihapus");
|
||||
apbdes.findMany.load();
|
||||
} else {
|
||||
toast.error(result?.message || "Gagal menghapus apbdes");
|
||||
toast.error(res.data?.message || "Gagal menghapus APBDes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus apbdes");
|
||||
} catch (error: any) {
|
||||
console.error("Delete error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat menghapus");
|
||||
} finally {
|
||||
apbdes.delete.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
edit: {
|
||||
id: "",
|
||||
form: { ...defaultapbdesForm },
|
||||
form: { ...defaultApbdesForm },
|
||||
loading: false,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
try {
|
||||
apbdes.edit.loading = true;
|
||||
this.loading = true;
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[id].get();
|
||||
|
||||
const response = await fetch(`/api/landingpage/apbdes/${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;
|
||||
if (res.data?.success) {
|
||||
const data = res.data.data;
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
name: data.name,
|
||||
jumlah: data.jumlah,
|
||||
imageId: data.imageId,
|
||||
fileId: data.fileId,
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
imageId: data.imageId || "",
|
||||
fileId: data.fileId || "",
|
||||
items: (data.items || []).map((item: any) => ({
|
||||
kode: item.kode,
|
||||
uraian: item.uraian,
|
||||
anggaran: item.anggaran,
|
||||
realisasi: item.realisasi,
|
||||
selisih: item.selisih,
|
||||
persentase: item.persentase,
|
||||
level: item.level,
|
||||
tipe: item.tipe || 'pendapatan',
|
||||
})),
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(result?.message || "Gagal memuat data");
|
||||
throw new Error(res.data?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading apbdes:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal memuat data"
|
||||
);
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error("Edit load error:", error);
|
||||
toast.error(error.message || "Gagal memuat data untuk diedit");
|
||||
} finally {
|
||||
apbdes.edit.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async update() {
|
||||
const cek = templateapbDesaForm.safeParse(apbdes.edit.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
const parsed = ApbdesFormSchema.safeParse(this.form);
|
||||
if (!parsed.success) {
|
||||
const errors = parsed.error.issues.map((issue) => `${issue.path.join(".")} - ${issue.message}`);
|
||||
toast.error(`Validasi gagal:\n${errors.join("\n")}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
apbdes.edit.loading = true;
|
||||
const response = await fetch(`/api/landingpage/apbdes/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: this.form.name,
|
||||
jumlah: this.form.jumlah,
|
||||
imageId: this.form.imageId,
|
||||
fileId: this.form.fileId,
|
||||
}),
|
||||
});
|
||||
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 apbdes");
|
||||
await apbdes.findMany.load(); // refresh list
|
||||
this.loading = true;
|
||||
// Include the ID in the request body
|
||||
const requestData = {
|
||||
...parsed.data,
|
||||
id: this.id, // Add the ID to the request body
|
||||
};
|
||||
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[this.id].put(requestData);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("APBDes berhasil diperbarui");
|
||||
apbdes.findMany.load();
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal mengupdate apbdes");
|
||||
throw new Error(res.data?.message || "Gagal memperbarui APBDes");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating apbdes:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal mengupdate apbdes"
|
||||
);
|
||||
} catch (error: any) {
|
||||
console.error("Update error:", error);
|
||||
toast.error(error.message || "Gagal memperbarui APBDes");
|
||||
return false;
|
||||
} finally {
|
||||
apbdes.edit.loading = false;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
addItem(item: Partial<z.infer<typeof ApbdesItemSchema>>) {
|
||||
const normalized = normalizeItem(item);
|
||||
this.form.items.push(normalized);
|
||||
},
|
||||
|
||||
removeItem(index: number) {
|
||||
this.form.items.splice(index, 1);
|
||||
},
|
||||
|
||||
reset() {
|
||||
apbdes.edit.id = "";
|
||||
apbdes.edit.form = { ...defaultapbdesForm };
|
||||
this.id = "";
|
||||
this.form = { ...defaultApbdesForm };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apbdes;
|
||||
export default apbdes;
|
||||
@@ -27,7 +27,7 @@ const programInovasi = proxy({
|
||||
name: "",
|
||||
description: "",
|
||||
imageId: "",
|
||||
link: ""
|
||||
link: "",
|
||||
} as ProgramInovasiForm,
|
||||
loading: false,
|
||||
async create() {
|
||||
@@ -53,7 +53,7 @@ const programInovasi = proxy({
|
||||
].post(formData);
|
||||
if (res.status === 200) {
|
||||
programInovasi.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed 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);
|
||||
@@ -474,7 +482,7 @@ const mediaSosial = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
mediaSosial.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
@@ -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({
|
||||
|
||||
@@ -93,6 +93,34 @@ const sdgsDesa = proxy({
|
||||
}
|
||||
},
|
||||
},
|
||||
findManyAll: {
|
||||
data: null as any[] | null,
|
||||
loading: false,
|
||||
load: async () => { // Change to arrow function
|
||||
sdgsDesa.findManyAll.loading = true; // Use the full path to access the property
|
||||
try {
|
||||
const query: any = {};
|
||||
|
||||
const res = await ApiFetch.api.landingpage.sdgsdesa[
|
||||
"findManyAll"
|
||||
].get({
|
||||
query,
|
||||
});
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
sdgsDesa.findManyAll.data = res.data.data || [];
|
||||
} else {
|
||||
console.error("Failed to load media sosial:", res.data?.message);
|
||||
sdgsDesa.findManyAll.data = [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading media sosial:", error);
|
||||
sdgsDesa.findManyAll.data = [];
|
||||
} finally {
|
||||
sdgsDesa.findManyAll.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
findUnique: {
|
||||
data: null as Prisma.SdgsDesaGetPayload<{
|
||||
include: {
|
||||
|
||||
@@ -39,7 +39,7 @@ const dataLingkunganDesaState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
dataLingkunganDesaState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -35,7 +35,7 @@ const pengelolaanSampah = proxy({
|
||||
].post(pengelolaanSampah.create.form);
|
||||
if (res.status === 200) {
|
||||
pengelolaanSampah.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -39,7 +39,7 @@ const programPenghijauanState = proxy({
|
||||
);
|
||||
if (res.status === 200) {
|
||||
programPenghijauanState.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
console.log(res);
|
||||
return toast.error("failed create");
|
||||
|
||||
@@ -9,34 +9,32 @@ import { z } from "zod";
|
||||
|
||||
const templateBeasiswaPendaftar = z.object({
|
||||
namaLengkap: z.string().min(1, "Nama harus diisi"),
|
||||
nik: z.string().min(1, "NIK harus diisi"),
|
||||
nis: z.string().min(1, "NIS harus diisi"),
|
||||
kelas: z.string().min(1, "Kelas harus diisi"),
|
||||
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
|
||||
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
|
||||
tempatLahir: z.string().min(1, "Tempat lahir harus diisi"),
|
||||
tanggalLahir: z.string().min(1, "Tanggal lahir harus diisi"),
|
||||
jenisKelamin: z.string().min(1, "Jenis kelamin harus diisi"),
|
||||
kewarganegaraan: z.string().min(1, "Kewarganegaraan harus diisi"),
|
||||
agama: z.string().min(1, "Agama harus diisi"),
|
||||
alamatKTP: z.string().min(1, "Alamat KTP harus diisi"),
|
||||
alamatDomisili: z.string().min(1, "Alamat domisili harus diisi"),
|
||||
namaOrtu: z.string().min(1, "Nama ortu harus diisi"),
|
||||
nik: z.string().min(1, "NIK harus diisi"),
|
||||
pekerjaanOrtu: z.string().min(1, "Pekerjaan ortu harus diisi"),
|
||||
penghasilan: z.string().min(1, "Penghasilan ortu harus diisi"),
|
||||
noHp: z.string().min(1, "No HP harus diisi"),
|
||||
email: z.string().min(1, "Email harus diisi"),
|
||||
statusPernikahan: z.string().min(1, "Status pernikahan harus diisi"),
|
||||
ukuranBaju: z.string().min(1, "Ukuran baju harus diisi"),
|
||||
});
|
||||
|
||||
const defaultBeasiswaPendaftar = {
|
||||
namaLengkap: "",
|
||||
nik: "",
|
||||
nis: "",
|
||||
kelas: "",
|
||||
jenisKelamin: "",
|
||||
alamatDomisili: "",
|
||||
tempatLahir: "",
|
||||
tanggalLahir: "",
|
||||
jenisKelamin: "",
|
||||
kewarganegaraan: "",
|
||||
agama: "",
|
||||
alamatKTP: "",
|
||||
alamatDomisili: "",
|
||||
namaOrtu: "",
|
||||
nik: "",
|
||||
pekerjaanOrtu: "",
|
||||
penghasilan: "",
|
||||
noHp: "",
|
||||
email: "",
|
||||
statusPernikahan: "",
|
||||
ukuranBaju: "",
|
||||
};
|
||||
|
||||
const beasiswaPendaftar = proxy({
|
||||
@@ -200,18 +198,17 @@ const beasiswaPendaftar = proxy({
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
namaLengkap: data.namaLengkap,
|
||||
nik: data.nik,
|
||||
nis: data.nis,
|
||||
kelas: data.kelas,
|
||||
jenisKelamin: data.jenisKelamin,
|
||||
alamatDomisili: data.alamatDomisili,
|
||||
tempatLahir: data.tempatLahir,
|
||||
tanggalLahir: data.tanggalLahir,
|
||||
jenisKelamin: data.jenisKelamin,
|
||||
kewarganegaraan: data.kewarganegaraan,
|
||||
agama: data.agama,
|
||||
alamatKTP: data.alamatKTP,
|
||||
alamatDomisili: data.alamatDomisili,
|
||||
namaOrtu: data.namaOrtu,
|
||||
nik: data.nik,
|
||||
pekerjaanOrtu: data.pekerjaanOrtu,
|
||||
penghasilan: data.penghasilan,
|
||||
noHp: data.noHp,
|
||||
email: data.email,
|
||||
statusPernikahan: data.statusPernikahan,
|
||||
ukuranBaju: data.ukuranBaju,
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
@@ -249,17 +246,17 @@ const beasiswaPendaftar = proxy({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
namaLengkap: this.form.namaLengkap,
|
||||
nik: this.form.nik,
|
||||
tanggalLahir: this.form.tanggalLahir,
|
||||
nis: this.form.nis,
|
||||
kelas: this.form.kelas,
|
||||
jenisKelamin: this.form.jenisKelamin,
|
||||
kewarganegaraan: this.form.kewarganegaraan,
|
||||
agama: this.form.agama,
|
||||
alamatKTP: this.form.alamatKTP,
|
||||
alamatDomisili: this.form.alamatDomisili,
|
||||
tempatLahir: this.form.tempatLahir,
|
||||
tanggalLahir: this.form.tanggalLahir,
|
||||
namaOrtu: this.form.namaOrtu,
|
||||
nik: this.form.nik,
|
||||
pekerjaanOrtu: this.form.pekerjaanOrtu,
|
||||
penghasilan: this.form.penghasilan,
|
||||
noHp: this.form.noHp,
|
||||
email: this.form.email,
|
||||
statusPernikahan: this.form.statusPernikahan,
|
||||
ukuranBaju: this.form.ukuranBaju,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ const dataPendidikan = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
dataPendidikan.create.form = {
|
||||
name: "",
|
||||
jumlah: "",
|
||||
|
||||
@@ -38,7 +38,7 @@ const daftarInformasiPublik = proxy({
|
||||
].post(daftarInformasiPublik.create.form);
|
||||
if (res.status === 200) {
|
||||
daftarInformasiPublik.findMany.load();
|
||||
return toast.success("success create");
|
||||
return toast.success("Sukses menambahkan");
|
||||
}
|
||||
return toast.error("failed create");
|
||||
} catch (error) {
|
||||
|
||||
@@ -41,7 +41,7 @@ const grafikBerdasarkanUmur = proxy({
|
||||
if (res.status === 200) {
|
||||
const id = res.data?.data?.id;
|
||||
if (id) {
|
||||
toast.success("Success create");
|
||||
toast.success("Sukses menambahkan");
|
||||
grafikBerdasarkanUmur.create.form = {
|
||||
remaja: "",
|
||||
dewasa: "",
|
||||
|
||||
@@ -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("success create");
|
||||
}
|
||||
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;
|
||||
|
||||
@@ -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("success create");
|
||||
}
|
||||
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;
|
||||
|
||||
|
||||
@@ -3,9 +3,6 @@ import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Schema validasi form ProfilePPID menggunakan Zod.
|
||||
*/
|
||||
const templateForm = z.object({
|
||||
name: z.string().min(3, "Nama minimal 3 karakter"),
|
||||
biodata: z.string().min(3, "Biodata minimal 3 karakter"),
|
||||
@@ -33,25 +30,16 @@ type ProfilePPIDForm = Prisma.ProfilePPIDGetPayload<{
|
||||
pengalaman: true;
|
||||
unggulan: true;
|
||||
imageId: true;
|
||||
image?: {
|
||||
select: {
|
||||
link: true;
|
||||
};
|
||||
};
|
||||
image?: { select: { link: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
/**
|
||||
* Improved State Management - Consolidated and more robust
|
||||
*/
|
||||
const stateProfilePPID = proxy({
|
||||
// Consolidated data management
|
||||
profile: {
|
||||
data: null as ProfilePPIDForm | null,
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
|
||||
// Single method to load profile data
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
@@ -62,52 +50,42 @@ const stateProfilePPID = proxy({
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ppid/profileppid/${id}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const res = await fetch(`/api/ppid/profileppid/${id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
const result = await res.json();
|
||||
if (result.success) {
|
||||
this.data = result.data;
|
||||
return result.data;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal mengambil data profile");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
this.error = errorMessage;
|
||||
console.error("Load profile error:", errorMessage);
|
||||
toast.error("Terjadi kesalahan saat mengambil data profile");
|
||||
} else throw new Error(result.message || "Gagal memuat data profile");
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
this.error = msg;
|
||||
console.error("Load profile error:", msg);
|
||||
toast.error("Gagal memuat data profile");
|
||||
return null;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Reset profile data
|
||||
reset() {
|
||||
this.data = null;
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Edit form management
|
||||
editForm: {
|
||||
id: "",
|
||||
form: { ...defaultForm },
|
||||
originalForm: { ...defaultForm }, // ✅ Tambah field originalForm
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
isReadOnly: false, // Flag untuk data yang tidak bisa diedit
|
||||
|
||||
// Initialize form with profile data
|
||||
initialize(profileData: ProfilePPIDForm) {
|
||||
this.id = profileData.id;
|
||||
this.isReadOnly = false; // Semua data bisa diedit
|
||||
this.form = {
|
||||
const data = {
|
||||
name: profileData.name || "",
|
||||
biodata: profileData.biodata || "",
|
||||
riwayat: profileData.riwayat || "",
|
||||
@@ -115,23 +93,20 @@ const stateProfilePPID = proxy({
|
||||
unggulan: profileData.unggulan || "",
|
||||
imageId: profileData.imageId || "",
|
||||
};
|
||||
this.form = { ...data };
|
||||
this.originalForm = { ...data }; // ✅ Simpan versi original
|
||||
},
|
||||
|
||||
// Update form field
|
||||
updateField(field: keyof typeof defaultForm, value: string) {
|
||||
this.form[field] = value;
|
||||
},
|
||||
|
||||
// Submit form
|
||||
async submit() {
|
||||
// Validate form
|
||||
const validation = templateForm.safeParse(this.form);
|
||||
|
||||
if (!validation.success) {
|
||||
const errors = validation.error.issues
|
||||
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
|
||||
.join(", ");
|
||||
toast.error(`Form tidak valid: ${errors}`);
|
||||
const check = templateForm.safeParse(this.form);
|
||||
if (!check.success) {
|
||||
toast.error(
|
||||
check.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ")
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -139,63 +114,54 @@ const stateProfilePPID = proxy({
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ppid/profileppid/${this.id}`, {
|
||||
const res = await fetch(`/api/ppid/profileppid/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(this.form),
|
||||
});
|
||||
|
||||
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 (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
toast.success("Berhasil update profile");
|
||||
// Refresh profile data
|
||||
await stateProfilePPID.profile.load(this.id);
|
||||
this.originalForm = { ...this.form }; // ✅ Update original setelah sukses
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update profile");
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = (error as Error).message;
|
||||
this.error = errorMessage;
|
||||
console.error("Update profile error:", errorMessage);
|
||||
toast.error("Terjadi kesalahan saat update profile");
|
||||
} else throw new Error(result.message || "Gagal update profile");
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
this.error = msg;
|
||||
toast.error(msg);
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
// Reset form
|
||||
// ✅ Tambahan reset ke original data
|
||||
resetToOriginal() {
|
||||
this.form = { ...this.originalForm };
|
||||
toast.info("Data dikembalikan ke kondisi awal");
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.id = "";
|
||||
this.form = { ...defaultForm };
|
||||
this.originalForm = { ...defaultForm };
|
||||
this.error = null;
|
||||
this.loading = false;
|
||||
this.isReadOnly = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// Helper methods
|
||||
async loadForEdit(id: string) {
|
||||
const profileData = await this.profile.load(id);
|
||||
if (profileData) {
|
||||
this.editForm.initialize(profileData);
|
||||
}
|
||||
return profileData;
|
||||
const data = await this.profile.load(id);
|
||||
if (data) this.editForm.initialize(data);
|
||||
return data;
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.profile.reset();
|
||||
this.editForm.reset();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default stateProfilePPID;
|
||||
export default stateProfilePPID;
|
||||
|
||||
@@ -90,42 +90,96 @@ const userState = proxy({
|
||||
}
|
||||
},
|
||||
},
|
||||
updateActive: {
|
||||
deleteUser: {
|
||||
loading: false,
|
||||
async submit(id: string, isActive: boolean) {
|
||||
this.loading = true;
|
||||
|
||||
async delete(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/user/updt`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, isActive }),
|
||||
userState.deleteUser.loading = true;
|
||||
|
||||
const response = await fetch(`/api/user/delUser/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.status === 200 && data.success) {
|
||||
toast.success(data.message);
|
||||
userState.findMany.load(userState.findMany.page, 10, userState.findMany.search);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result?.success) {
|
||||
toast.success(result.message || "User berhasil dihapus permanen");
|
||||
await userState.findMany.load(); // refresh list user setelah delete
|
||||
} else {
|
||||
toast.error(data.message || "Gagal update status user");
|
||||
toast.error(result?.message || "Gagal menghapus user");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
toast.error("Gagal update status user");
|
||||
} catch (error) {
|
||||
console.error("Gagal delete user:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus user");
|
||||
} finally {
|
||||
this.loading = false;
|
||||
userState.deleteUser.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
// Di file userState.ts atau dimana state user berada
|
||||
|
||||
update: {
|
||||
loading: false,
|
||||
|
||||
async submit(payload: { id: string; isActive?: boolean; roleId?: string }) {
|
||||
this.loading = true;
|
||||
try {
|
||||
const res = await fetch(`/api/user/updt`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.status === 200 && data.success) {
|
||||
// ✅ Tampilkan pesan yang berbeda jika role berubah
|
||||
if (data.roleChanged) {
|
||||
toast.success(
|
||||
`${data.message}\n\nUser akan logout otomatis dalam beberapa detik.`,
|
||||
{
|
||||
autoClose: 5000,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
toast.success(data.message);
|
||||
}
|
||||
|
||||
// Refresh list
|
||||
await userState.findMany.load(
|
||||
userState.findMany.page,
|
||||
10,
|
||||
userState.findMany.search
|
||||
);
|
||||
|
||||
return true; // ✅ Return success untuk handling di component
|
||||
} else {
|
||||
toast.error(data.message || "Gagal update user");
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("❌ Error update user:", e);
|
||||
toast.error("Gagal update user");
|
||||
return false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const templateRole = z.object({
|
||||
name: z.string().min(1, "Nama harus diisi"),
|
||||
permissions: z.array(z.string()).min(1, "Permission harus diisi"),
|
||||
});
|
||||
|
||||
const defaultRole = {
|
||||
name: "",
|
||||
permissions: [] as string[],
|
||||
};
|
||||
|
||||
const roleState = proxy({
|
||||
@@ -237,7 +291,7 @@ const roleState = proxy({
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/role/${id}`, {
|
||||
method: "GET",
|
||||
@@ -245,31 +299,25 @@ const roleState = proxy({
|
||||
"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 = {
|
||||
|
||||
// langsung set melalui root state, bukan this
|
||||
roleState.update.id = data.id;
|
||||
roleState.update.form = {
|
||||
name: data.name,
|
||||
permissions: data.permissions,
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
throw new Error(result?.message || "Gagal memuat data");
|
||||
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading role:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal memuat data"
|
||||
);
|
||||
return null;
|
||||
toast.error("Gagal memuat data");
|
||||
}
|
||||
},
|
||||
},
|
||||
async update() {
|
||||
const cek = templateRole.safeParse(roleState.update.form);
|
||||
if (!cek.success) {
|
||||
@@ -290,7 +338,6 @@ const roleState = proxy({
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: this.form.name,
|
||||
permissions: this.form.permissions,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,104 +1,103 @@
|
||||
'use client'
|
||||
import { apiFetchLogin } from '@/app/admin/auth/_lib/api_fetch_auth';
|
||||
'use client';
|
||||
import { apiFetchLogin } from '@/app/api/auth/_lib/api_fetch_auth';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Center, Flex, Image, Paper, Stack, Text, Title } from '@mantine/core';
|
||||
import Link from 'next/link';
|
||||
import { Box, Button, Center, Image, Paper, Stack, Title } from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { PhoneInput } from "react-international-phone";
|
||||
import "react-international-phone/style.css";
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
import 'react-international-phone/style.css';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
|
||||
|
||||
function Login() {
|
||||
const router = useRouter()
|
||||
const [phone, setPhone] = useState("")
|
||||
const [isError, setError] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Login.tsx
|
||||
async function onLogin() {
|
||||
const nomor = phone.substring(1);
|
||||
if (nomor.length <= 4) return setError(true)
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
console.log(cleanPhone);
|
||||
if (cleanPhone.length < 10) {
|
||||
toast.error('Nomor telepon tidak valid');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await apiFetchLogin({ nomor: nomor })
|
||||
if (response && response.success) {
|
||||
localStorage.setItem("hipmi_auth_code_id", response.kodeId);
|
||||
toast.success(response.message);
|
||||
router.push("/validasi", { scroll: false });
|
||||
const response = await apiFetchLogin({ nomor: cleanPhone });
|
||||
|
||||
console.log(response);
|
||||
|
||||
if (!response.success) {
|
||||
toast.error(response.message || 'Gagal memproses login');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simpan nomor untuk register
|
||||
localStorage.setItem('auth_nomor', cleanPhone);
|
||||
if (response.isRegistered) {
|
||||
// ✅ User lama: simpan kodeId
|
||||
localStorage.setItem('auth_kodeId', response.kodeId);
|
||||
|
||||
// ✅ Cookie sudah di-set oleh API, langsung redirect
|
||||
router.push('/validasi'); // Clean URL
|
||||
} else {
|
||||
setLoading(false);
|
||||
toast.error(response?.message);
|
||||
// ❌ User baru: langsung ke registrasi (tanpa kodeId)
|
||||
router.push('/registrasi');
|
||||
}
|
||||
} catch (error) {
|
||||
setLoading(false)
|
||||
console.log("Error Login", error)
|
||||
toast.error("Terjadi kesalahan saat login")
|
||||
console.error('Error Login:', error);
|
||||
toast.error('Terjadi kesalahan saat login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.Bg}>
|
||||
<Stack pos="relative" bg={colors.Bg}>
|
||||
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
||||
<Stack align='center' justify='center' h={"100vh"}>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center' gap={"lg"}>
|
||||
<Stack align="center" justify="center" h="100vh">
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||
<Stack align="center" gap="lg">
|
||||
<Box>
|
||||
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
|
||||
Login
|
||||
</Title>
|
||||
<Center>
|
||||
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
|
||||
<Image
|
||||
loading="lazy"
|
||||
src="/darmasaba-icon.png"
|
||||
alt="Logo"
|
||||
w={80}
|
||||
h={80}
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
{/* <Box mb={10}>
|
||||
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masuk Untuk Akses Admin</Text>
|
||||
<TextInput
|
||||
label='Username'
|
||||
placeholder='Username'
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</Box> */}
|
||||
<Box w="100%">
|
||||
<PhoneInput
|
||||
countrySelectorStyleProps={{
|
||||
buttonStyle: {
|
||||
backgroundColor: colors['blue-button'],
|
||||
},
|
||||
}}
|
||||
inputStyle={{ width: "100%"}}
|
||||
inputStyle={{ width: '100%' }}
|
||||
defaultCountry="id"
|
||||
onChange={(val) => {
|
||||
setPhone(val);
|
||||
}}
|
||||
value={phone}
|
||||
onChange={(val) => setPhone(val)}
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
toast.error("Masukan nomor telepon anda")
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Box py={20} >
|
||||
<Box py={20}>
|
||||
<Button
|
||||
fullWidth
|
||||
bg={colors['blue-button']}
|
||||
radius={'xl'}
|
||||
radius="xl"
|
||||
onClick={onLogin}
|
||||
loading={loading ? true : false}
|
||||
>Masuk
|
||||
loading={loading}
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
</Box>
|
||||
<Flex justify={'center'} align={'center'}>
|
||||
<Text>Belum punya akun? </Text>
|
||||
<Button variant='transparent' component={Link} href={'/registrasi'}>
|
||||
<Text c={colors['blue-button']} fw={'bold'}>Registrasi</Text>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -108,4 +107,4 @@ function Login() {
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
export default Login;
|
||||
@@ -1,113 +1,153 @@
|
||||
/* eslint-disable @typescript-eslint/no-unused-expressions */
|
||||
'use client'
|
||||
import { apiFetchRegister } from '@/app/admin/auth/_lib/api_fetch_auth';
|
||||
// app/registrasi/page.tsx
|
||||
'use client';
|
||||
|
||||
import { apiFetchRegister } from '@/app/api/auth/_lib/api_fetch_auth';
|
||||
import BackButton from '@/app/darmasaba/(pages)/desa/layanan/_com/BackButto';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
|
||||
import {
|
||||
Box, Button, Center, Checkbox, Image, Paper, Stack, Text, TextInput, Title,
|
||||
} from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { PhoneInput } from "react-international-phone";
|
||||
import "react-international-phone/style.css";
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
import 'react-international-phone/style.css';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
function Registrasi() {
|
||||
const [phone, setPhone] = useState("")
|
||||
const router = useRouter()
|
||||
const [value, setValue] = useState("")
|
||||
const [isValue, setIsValue] = useState(false);
|
||||
export default function Registrasi() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [phone, setPhone] = useState(''); // ✅ tambahkan state untuk phone
|
||||
const [agree, setAgree] = useState(false)
|
||||
|
||||
async function onRegistarsi() {
|
||||
if (value.length < 5) {
|
||||
toast.error("Username minimal 5 karakter!");
|
||||
// Ambil data dari localStorage (dari login)
|
||||
useEffect(() => {
|
||||
const storedNomor = localStorage.getItem('auth_nomor');
|
||||
if (!storedNomor) {
|
||||
toast.error('Akses tidak valid');
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.includes(" ")) {
|
||||
toast.error("Username tidak boleh ada spasi!");
|
||||
setPhone(storedNomor);
|
||||
}, [router]);
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username || username.trim().length < 5) {
|
||||
toast.error('Username minimal 5 karakter!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
toast.error("Nomor telepon wajib diisi!");
|
||||
if (username.includes(' ')) {
|
||||
toast.error('Username tidak boleh ada spasi!');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
if (cleanPhone.length < 10) {
|
||||
toast.error('Nomor tidak valid!');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!agree) {
|
||||
toast.error("Anda harus menyetujui syarat dan ketentuan!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const respone = await apiFetchRegister({ nomor: phone, username: value });
|
||||
// ✅ Hanya kirim username & nomor → dapat kodeId
|
||||
const response = await apiFetchRegister({ username, nomor: cleanPhone });
|
||||
|
||||
if (respone.success) {
|
||||
router.push("/login", { scroll: false });
|
||||
toast.success(respone.message);
|
||||
if (response.success) {
|
||||
// Simpan sementara
|
||||
localStorage.setItem('auth_kodeId', response.kodeId);
|
||||
localStorage.setItem('auth_username', username); // simpan username
|
||||
|
||||
} else {
|
||||
setLoading(false);
|
||||
toast.error(respone.message);
|
||||
toast.success('Kode verifikasi dikirim!');
|
||||
router.push('/validasi'); // ✅ ke halaman validasi
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error Registrasi:', error);
|
||||
toast.error('Gagal mengirim OTP');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
console.log("Error Registrasi", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.Bg} gap={"22"} py={"xl"} h={"100vh"}>
|
||||
<Stack pos="relative" bg={colors.Bg} gap="22" py="xl" h="100vh">
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<BackButton />
|
||||
</Box>
|
||||
<Box px={{ base: 'md', md: 100 }}>
|
||||
<Stack justify='center' align='center' h={"80vh"}>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center'>
|
||||
<Title order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
<Stack justify="center" align="center" h="80vh">
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||
<Stack align="center">
|
||||
<Title order={2} fw="bold" c={colors['blue-button']}>
|
||||
Registrasi
|
||||
</Title>
|
||||
<Center>
|
||||
<Image loading="lazy" src={"/darmasaba-icon.png"} alt="" w={80} />
|
||||
<Image loading="lazy" src="/darmasaba-icon.png" alt="" w={80} />
|
||||
</Center>
|
||||
<Box>
|
||||
<TextInput placeholder='Username'
|
||||
label='Username'
|
||||
maxLength={50}
|
||||
|
||||
<Box w="100%">
|
||||
<TextInput
|
||||
label="Username"
|
||||
placeholder="Username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
error={
|
||||
value.length > 0 && value.length < 5
|
||||
? "Minimal 5 karakter !"
|
||||
: value.includes(" ")
|
||||
? "Tidak boleh ada spasi"
|
||||
: isValue
|
||||
? "Masukan username anda"
|
||||
: ""
|
||||
username.length > 0 && username.length < 5
|
||||
? 'Minimal 5 karakter!'
|
||||
: username.includes(' ')
|
||||
? 'Tidak boleh ada spasi'
|
||||
: ''
|
||||
}
|
||||
onChange={(val) => {
|
||||
val.currentTarget.value.length > 0 ? setIsValue(false) : "";
|
||||
setValue(val.currentTarget.value);
|
||||
}}
|
||||
required
|
||||
|
||||
/>
|
||||
<Box py={10}>
|
||||
<Text fz={"sm"} >Nomor Telepon</Text>
|
||||
|
||||
<Box pt="md">
|
||||
<Text fz="sm">Nomor Telepon</Text>
|
||||
<PhoneInput
|
||||
countrySelectorStyleProps={{
|
||||
buttonStyle: {
|
||||
backgroundColor: colors['blue-button'],
|
||||
},
|
||||
}}
|
||||
inputStyle={{ width: "100%" }}
|
||||
defaultCountry="id"
|
||||
onChange={(val) => {
|
||||
setPhone(val);
|
||||
}}
|
||||
value={phone}
|
||||
disabled
|
||||
/>
|
||||
</Box>
|
||||
<Box pb={10}>
|
||||
|
||||
<Box pt="md">
|
||||
<Checkbox
|
||||
label="Saya menyetujui syarat dan ketentuan yang berlaku"
|
||||
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 pb={20} >
|
||||
<Button fullWidth bg={colors['blue-button']} radius={'xl'} onClick={onRegistarsi} loading={loading ? true : false}>Daftar</Button>
|
||||
|
||||
|
||||
<Box pt="xl">
|
||||
<Button
|
||||
fullWidth
|
||||
bg={colors['blue-button']}
|
||||
radius="xl"
|
||||
onClick={handleRegister}
|
||||
loading={loading}
|
||||
disabled={username.length < 5}
|
||||
>
|
||||
Kirim Kode Verifikasi
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
@@ -116,6 +156,4 @@ function Registrasi() {
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Registrasi;
|
||||
}
|
||||
@@ -1,31 +1,306 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Paper, PinInput, Stack, Text, Title } from '@mantine/core';
|
||||
import { useRouter } from 'next/navigation';
|
||||
'use client';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
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';
|
||||
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();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const storedKodeId = localStorage.getItem('auth_kodeId');
|
||||
if (!storedKodeId) {
|
||||
toast.error('Akses tidak valid');
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
setKodeId(storedKodeId);
|
||||
const loadOtpData = async () => {
|
||||
try {
|
||||
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');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Gagal memuat data OTP:', error);
|
||||
toast.error('Kode verifikasi tidak valid');
|
||||
router.replace('/login');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadOtpData();
|
||||
}, [router]);
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (!kodeId || !nomor || otp.length < 4) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (isRegistrationFlow) {
|
||||
await handleRegistrationVerification();
|
||||
} else {
|
||||
await handleLoginVerification();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saat verifikasi:', error);
|
||||
toast.error('Terjadi kesalahan sistem');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegistrationVerification = async () => {
|
||||
const username = localStorage.getItem('auth_username');
|
||||
if (!username) {
|
||||
toast.error('Data registrasi tidak ditemukan.');
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanNomor = nomor?.replace(/\D/g, '') ?? '';
|
||||
if (cleanNomor.length < 10 || username.trim().length < 5) {
|
||||
toast.error('Data tidak valid');
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ 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'
|
||||
});
|
||||
|
||||
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 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', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ nomor }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
localStorage.setItem('auth_kodeId', data.kodeId);
|
||||
toast.success('OTP baru dikirim');
|
||||
} else {
|
||||
toast.error(data.message || 'Gagal mengirim ulang OTP');
|
||||
}
|
||||
} catch {
|
||||
toast.error('Gagal menghubungi server');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Stack pos="relative" bg={colors.Bg} align="center" justify="center" h="100vh">
|
||||
<Loader size="md" color={colors['blue-button']} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!nomor) return null;
|
||||
|
||||
function Validasi() {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<Stack pos={"relative"} bg={colors.Bg}>
|
||||
<Stack pos="relative" bg={colors.Bg}>
|
||||
<Box px={{ base: 'md', md: 100 }} pb={50}>
|
||||
<Stack align='center' justify='center' h={"100vh"}>
|
||||
<Paper p={'xl'} radius={'md'} bg={colors['white-trans-1']}>
|
||||
<Stack align='center' gap={"lg"}>
|
||||
<Stack align="center" justify="center" h="100vh">
|
||||
<Paper p="xl" radius="md" bg={colors['white-trans-1']} w={{ base: '100%', sm: 400 }}>
|
||||
<Stack align="center" gap="lg">
|
||||
<Box>
|
||||
<Title ta={"center"} order={2} fw={'bold'} c={colors['blue-button']}>
|
||||
Kode Verifikasi
|
||||
<Title ta="center" order={2} fw="bold" c={colors['blue-button']}>
|
||||
{isRegistrationFlow ? 'Verifikasi Registrasi' : 'Verifikasi Login'}
|
||||
</Title>
|
||||
<Text ta="center" size="sm" c="dimmed" mt="xs">
|
||||
Kami telah mengirim kode ke nomor <strong>{nomor}</strong>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box mb={10}>
|
||||
<Text c={colors['blue-button']} ta={"center"} fz={"sm"} fw={'bold'}>Masukkan Kode Verifikasi</Text>
|
||||
<PinInput type={/^[0-9]*$/} inputType="tel" inputMode="numeric" />
|
||||
<Box w="100%">
|
||||
<Box mb={20}>
|
||||
<Text c={colors['blue-button']} ta="center" fz="sm" fw="bold">
|
||||
Masukkan Kode Verifikasi
|
||||
</Text>
|
||||
<Center>
|
||||
<PinInput
|
||||
length={4}
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
onComplete={handleVerify}
|
||||
inputMode="numeric"
|
||||
size="lg"
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
<Box py={20} >
|
||||
<Button onClick={() => router.push("/admin/landing-page/profile/program-inovasi")}>
|
||||
Page
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
onClick={handleVerify}
|
||||
loading={loading}
|
||||
disabled={otp.length < 4}
|
||||
bg={colors['blue-button']}
|
||||
radius="xl"
|
||||
>
|
||||
Verifikasi
|
||||
</Button>
|
||||
|
||||
<Text ta="center" size="sm" mt="md">
|
||||
Tidak menerima kode?{' '}
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={handleResend}
|
||||
size="xs"
|
||||
p={0}
|
||||
h="auto"
|
||||
color={colors['blue-button']}
|
||||
>
|
||||
Kirim Ulang
|
||||
</Button>
|
||||
</Box>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Paper>
|
||||
@@ -33,6 +308,4 @@ function Validasi() {
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default Validasi;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconBuildingStore, IconFileText, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IconFileText, IconBuildingStore, IconSparkles, IconUsers, IconUsersPlus } from '@tabler/icons-react';
|
||||
|
||||
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
@@ -14,36 +14,31 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
label: "Pelayanan Surat Keterangan",
|
||||
value: "pelayanansuratketerangan",
|
||||
href: "/admin/desa/layanan/pelayanan_surat_keterangan",
|
||||
icon: <IconFileText size={18} stroke={1.8} />,
|
||||
tooltip: "Layanan terkait surat keterangan resmi desa"
|
||||
icon: <IconFileText size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Pelayanan Perizinan Berusaha",
|
||||
value: "pelayananperizinanusaha",
|
||||
href: "/admin/desa/layanan/pelayanan_perizinan_berusaha",
|
||||
icon: <IconBuildingStore size={18} stroke={1.8} />,
|
||||
tooltip: "Layanan untuk izin usaha masyarakat"
|
||||
icon: <IconBuildingStore size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Pelayanan Telunjuk Sakti Desa",
|
||||
value: "pelayanantelunjuksaktidesa",
|
||||
href: "/admin/desa/layanan/pelayanan_telunjuk_sakti_desa",
|
||||
icon: <IconSparkles size={18} stroke={1.8} />,
|
||||
tooltip: "Layanan inovasi khusus desa"
|
||||
icon: <IconSparkles size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Pelayanan Penduduk Non-Permanent",
|
||||
value: "pelayanannonpermanent",
|
||||
href: "/admin/desa/layanan/pelayanan_penduduk_non_permanent",
|
||||
icon: <IconUsers size={18} stroke={1.8} />,
|
||||
tooltip: "Pendataan penduduk non-permanent"
|
||||
icon: <IconUsers size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Ajukan Permohonan",
|
||||
value: "ajukanpermohonan",
|
||||
href: "/admin/desa/layanan/ajukan_permohonan",
|
||||
icon: <IconUsersPlus size={18} stroke={1.8} />,
|
||||
tooltip: "Ajukan permohonan"
|
||||
icon: <IconUsersPlus size={18} stroke={1.8} />
|
||||
}
|
||||
];
|
||||
|
||||
@@ -91,14 +86,8 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<Tooltip
|
||||
key={i}
|
||||
label={tab.tooltip}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
||||
>
|
||||
<TabsTab
|
||||
key={i}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
@@ -109,7 +98,6 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
</Tooltip>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconCategory, IconNews } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IconNews, IconCategory } from '@tabler/icons-react';
|
||||
|
||||
function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
@@ -15,15 +15,13 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
||||
label: "List Berita",
|
||||
value: "list_berita",
|
||||
href: "/admin/desa/berita/list-berita",
|
||||
icon: <IconNews size={18} stroke={1.8} />,
|
||||
tooltip: "Lihat dan kelola semua berita desa"
|
||||
icon: <IconNews size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Kategori Berita",
|
||||
value: "kategori_berita",
|
||||
href: "/admin/desa/berita/kategori-berita",
|
||||
icon: <IconCategory size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola kategori berita desa"
|
||||
icon: <IconCategory size={18} stroke={1.8} />
|
||||
},
|
||||
];
|
||||
|
||||
@@ -71,46 +69,39 @@ function LayoutTabsBerita({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<Tooltip
|
||||
<TabsTab
|
||||
key={i}
|
||||
label={tab.tooltip}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<TabsTab
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
</Tooltip>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
|
||||
{tabs.map((tab, i) => (
|
||||
<TabsPanel
|
||||
key={i}
|
||||
value={tab.value}
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
{/* Konten dummy, bisa diganti sesuai routing */}
|
||||
<>{children}</>
|
||||
</TabsPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
{tabs.map((tab, i) => (
|
||||
<TabsPanel
|
||||
key={i}
|
||||
value={tab.value}
|
||||
style={{
|
||||
padding: "1.5rem",
|
||||
background: "linear-gradient(180deg, #ffffff, #f5f6fa)",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 4px 16px rgba(0,0,0,0.05)",
|
||||
}}
|
||||
>
|
||||
{/* Konten dummy, bisa diganti sesuai routing */}
|
||||
<>{children}</>
|
||||
</TabsPanel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Stack >
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -23,6 +23,11 @@ function EditKategoriBerita() {
|
||||
const editState = useProxy(stateDashboardBerita.kategoriBerita);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: '',
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
@@ -39,6 +44,9 @@ function EditKategoriBerita() {
|
||||
setFormData({
|
||||
name: data.name || '',
|
||||
});
|
||||
setOriginalData({
|
||||
name: data.name || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading kategori Berita:', error);
|
||||
@@ -56,8 +64,16 @@ function EditKategoriBerita() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
});
|
||||
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,
|
||||
@@ -70,6 +86,8 @@ function EditKategoriBerita() {
|
||||
} catch (error) {
|
||||
console.error('Error updating kategori Berita:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui kategori Berita');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -77,7 +95,6 @@ function EditKategoriBerita() {
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Back Button + Title */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
@@ -86,7 +103,6 @@ function EditKategoriBerita() {
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Kategori Berita
|
||||
</Title>
|
||||
@@ -95,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"
|
||||
@@ -112,6 +128,17 @@ function EditKategoriBerita() {
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -122,7 +149,7 @@ function EditKategoriBerita() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -9,15 +9,18 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Loader
|
||||
} 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 CreateKategoriBerita() {
|
||||
const createState = useProxy(stateDashboardBerita.kategoriBerita);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
createState.create.form = {
|
||||
@@ -26,16 +29,23 @@ function CreateKategoriBerita() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await createState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/berita/kategori-berita');
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/berita/kategori-berita');
|
||||
} catch (error) {
|
||||
console.error('Error creating kategori berita:', error);
|
||||
toast.error('Gagal menambahkan kategori berita');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header dengan back button */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
@@ -44,7 +54,6 @@ function CreateKategoriBerita() {
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Tambah Kategori Berita
|
||||
</Title>
|
||||
@@ -63,12 +72,23 @@ function CreateKategoriBerita() {
|
||||
<TextInput
|
||||
label="Nama Kategori Berita"
|
||||
placeholder="Masukkan nama kategori berita"
|
||||
defaultValue={createState.create.form.name || ''}
|
||||
value={createState.create.form.name || ''}
|
||||
onChange={(e) => (createState.create.form.name = 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"
|
||||
@@ -79,7 +99,7 @@ function CreateKategoriBerita() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -86,7 +85,6 @@ function ListKategoriBerita({ 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 Kategori Berita</Title>
|
||||
<Tooltip label="Tambah Kategori Berita" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
@@ -97,7 +95,6 @@ function ListKategoriBerita({ search }: { search: string }) {
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
@@ -123,7 +120,6 @@ function ListKategoriBerita({ search }: { search: string }) {
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Tooltip label="Edit Kategori Berita" withArrow>
|
||||
<Button
|
||||
variant="light"
|
||||
color="green"
|
||||
@@ -135,10 +131,8 @@ function ListKategoriBerita({ search }: { search: string }) {
|
||||
>
|
||||
<IconEdit size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Tooltip label="Hapus Kategori Berita" withArrow>
|
||||
<Button
|
||||
variant="light"
|
||||
color="red"
|
||||
@@ -150,7 +144,6 @@ function ListKategoriBerita({ search }: { search: string }) {
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
|
||||
@@ -6,6 +6,7 @@ import stateDashboardBerita from "@/app/admin/(dashboard)/_state/desa/berita";
|
||||
import colors from "@/con/colors";
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader
|
||||
} from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import {
|
||||
@@ -45,6 +46,17 @@ function EditBerita() {
|
||||
imageId: "",
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
kategoriBeritaId: "",
|
||||
content: "",
|
||||
imageId: "",
|
||||
imageUrl: ""
|
||||
});
|
||||
|
||||
// Load kategori + berita
|
||||
useEffect(() => {
|
||||
beritaState.kategoriBerita.findMany.load();
|
||||
@@ -64,6 +76,15 @@ function EditBerita() {
|
||||
imageId: data.imageId || "",
|
||||
});
|
||||
|
||||
setOriginalData({
|
||||
judul: data.judul || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
kategoriBeritaId: data.kategoriBeritaId || "",
|
||||
content: data.content || "",
|
||||
imageId: data.imageId || "",
|
||||
imageUrl: data.image?.link || ""
|
||||
});
|
||||
|
||||
if (data?.image?.link) {
|
||||
setPreviewImage(data.image.link);
|
||||
}
|
||||
@@ -83,6 +104,7 @@ function EditBerita() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Update global state hanya sekali di sini
|
||||
beritaState.berita.edit.form = {
|
||||
...beritaState.berita.edit.form,
|
||||
@@ -109,23 +131,36 @@ function EditBerita() {
|
||||
} catch (error) {
|
||||
console.error("Error updating berita:", error);
|
||||
toast.error("Terjadi kesalahan saat memperbarui berita");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
judul: originalData.judul,
|
||||
deskripsi: originalData.deskripsi,
|
||||
kategoriBeritaId: originalData.kategoriBeritaId,
|
||||
content: originalData.content,
|
||||
imageId: originalData.imageId,
|
||||
});
|
||||
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">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors["blue-button"]} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Berita
|
||||
</Title>
|
||||
@@ -219,14 +254,14 @@ function EditBerita() {
|
||||
Seret gambar atau klik untuk memilih file
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Maksimal 5MB, format gambar wajib
|
||||
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar"
|
||||
@@ -238,6 +273,24 @@ function EditBerita() {
|
||||
}}
|
||||
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>
|
||||
@@ -257,17 +310,29 @@ function EditBerita() {
|
||||
|
||||
{/* 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)",
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client'
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
|
||||
import { Box, Button, Group, Image, 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';
|
||||
|
||||
import colors from '@/con/colors';
|
||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||
import colors from '@/con/colors';
|
||||
|
||||
function DetailBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
@@ -111,7 +111,6 @@ function DetailBerita() {
|
||||
|
||||
{/* Action Button */}
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Berita" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -124,9 +123,7 @@ function DetailBerita() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Berita" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
||||
@@ -136,7 +133,6 @@ function DetailBerita() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader,
|
||||
ActionIcon
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
@@ -29,6 +30,7 @@ export default function CreateBerita() {
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useShallowEffect(() => {
|
||||
beritaState.kategoriBerita.findMany.load();
|
||||
@@ -47,42 +49,48 @@ export default function CreateBerita() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
||||
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');
|
||||
}
|
||||
|
||||
beritaState.berita.create.form.imageId = uploaded.id;
|
||||
|
||||
await beritaState.berita.create.create();
|
||||
|
||||
resetForm();
|
||||
router.push('/admin/desa/berita/list-berita');
|
||||
} catch (error) {
|
||||
console.error('Error creating berita:', error);
|
||||
toast.error('Terjadi kesalahan saat membuat berita');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
beritaState.berita.create.form.imageId = uploaded.id;
|
||||
|
||||
await beritaState.berita.create.create();
|
||||
|
||||
resetForm();
|
||||
router.push('/admin/desa/berita/list-berita');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header dengan tombol kembali */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Berita
|
||||
</Title>
|
||||
@@ -100,7 +108,7 @@ export default function CreateBerita() {
|
||||
<TextInput
|
||||
label="Judul"
|
||||
placeholder="Masukkan judul berita"
|
||||
defaultValue={beritaState.berita.create.form.judul}
|
||||
value={beritaState.berita.create.form.judul}
|
||||
onChange={(e) => (beritaState.berita.create.form.judul = e.target.value)}
|
||||
required
|
||||
/>
|
||||
@@ -112,7 +120,7 @@ export default function CreateBerita() {
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
defaultValue={beritaState.berita.create.form.kategoriBeritaId || null}
|
||||
value={beritaState.berita.create.form.kategoriBeritaId || null}
|
||||
onChange={(val: string | null) => {
|
||||
if (val) {
|
||||
const selected = beritaState.kategoriBerita.findMany.data?.find(
|
||||
@@ -157,7 +165,7 @@ export default function CreateBerita() {
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': [] }}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
@@ -178,7 +186,7 @@ export default function CreateBerita() {
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar"
|
||||
@@ -190,6 +198,26 @@ export default function CreateBerita() {
|
||||
}}
|
||||
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>
|
||||
@@ -207,6 +235,17 @@ export default function CreateBerita() {
|
||||
</Box>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -217,7 +256,7 @@ export default function CreateBerita() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
|
||||
@@ -68,7 +67,6 @@ function ListBerita({ 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 Berita</Title>
|
||||
<Tooltip label="Tambah Berita" withArrow>
|
||||
<Button
|
||||
leftSection={<IconCircleDashedPlus size={18} />}
|
||||
color="blue"
|
||||
@@ -77,7 +75,6 @@ function ListBerita({ search }: { search: string }) {
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
|
||||
303
src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx
Normal file
303
src/app/admin/(dashboard)/desa/gallery/foto/[id]/edit/page.tsx
Normal 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;
|
||||
175
src/app/admin/(dashboard)/desa/gallery/foto/[id]/page.tsx
Normal file
175
src/app/admin/(dashboard)/desa/gallery/foto/[id]/page.tsx
Normal 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;
|
||||
228
src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx
Normal file
228
src/app/admin/(dashboard)/desa/gallery/foto/create/page.tsx
Normal 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;
|
||||
@@ -1,160 +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,
|
||||
Tooltip,
|
||||
} 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;
|
||||
Title
|
||||
} 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">
|
||||
<Tooltip label="Hapus foto" withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
radius="md"
|
||||
onClick={() => {
|
||||
stateFileStorage
|
||||
.del({ id: v.id })
|
||||
.finally(() => toast("Foto berhasil dihapus"));
|
||||
}}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</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;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconPhoto, IconVideo } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IconPhoto, IconVideo } from '@tabler/icons-react';
|
||||
|
||||
function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
@@ -14,15 +14,13 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
||||
label: "Foto",
|
||||
value: "foto",
|
||||
href: "/admin/desa/gallery/foto",
|
||||
icon: <IconPhoto size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola foto-foto galeri desa"
|
||||
icon: <IconPhoto size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Video",
|
||||
value: "video",
|
||||
href: "/admin/desa/gallery/video",
|
||||
icon: <IconVideo size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola video galeri desa"
|
||||
icon: <IconVideo size={18} stroke={1.8} />
|
||||
},
|
||||
];
|
||||
|
||||
@@ -70,25 +68,18 @@ function LayoutTabsGallery({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<Tooltip
|
||||
<TabsTab
|
||||
key={i}
|
||||
label={tab.tooltip}
|
||||
position="bottom"
|
||||
withArrow
|
||||
transitionProps={{ transition: 'pop', duration: 200 }}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
<TabsTab
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
</Tooltip>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -4,6 +4,7 @@ import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
@@ -11,11 +12,11 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { IconArrowBack, IconX } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { convertYoutubeUrlToEmbed } from '../../../lib/youtube-utils';
|
||||
@@ -25,6 +26,14 @@ function EditVideo() {
|
||||
const videoState = useProxy(stateGallery.video);
|
||||
const params = useParams();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: "",
|
||||
deskripsi: "",
|
||||
linkVideo: "",
|
||||
});
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
@@ -45,6 +54,11 @@ function EditVideo() {
|
||||
deskripsi: data.deskripsi ?? '',
|
||||
linkVideo: data.linkVideo ?? '',
|
||||
});
|
||||
setOriginalData({
|
||||
name: data.name ?? '',
|
||||
deskripsi: data.deskripsi ?? '',
|
||||
linkVideo: data.linkVideo ?? '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading video:', error);
|
||||
@@ -62,25 +76,42 @@ function EditVideo() {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
|
||||
if (!converted) {
|
||||
toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
|
||||
return;
|
||||
}
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
linkVideo: originalData.linkVideo,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
videoState.update.form = {
|
||||
name: formData.name,
|
||||
deskripsi: formData.deskripsi,
|
||||
linkVideo: formData.linkVideo,
|
||||
};
|
||||
await videoState.update.update();
|
||||
toast.success('Video berhasil diperbarui!');
|
||||
router.push('/admin/desa/gallery/video');
|
||||
setIsSubmitting(true);
|
||||
const converted = convertYoutubeUrlToEmbed(formData.linkVideo);
|
||||
if (!converted) {
|
||||
toast.error("Link YouTube tidak valid. Pastikan formatnya benar.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
videoState.update.form = {
|
||||
name: formData.name,
|
||||
deskripsi: formData.deskripsi,
|
||||
linkVideo: formData.linkVideo,
|
||||
};
|
||||
await videoState.update.update();
|
||||
toast.success('Video berhasil diperbarui!');
|
||||
router.push('/admin/desa/gallery/video');
|
||||
} catch (error) {
|
||||
console.error('Error updating video:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui video');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating video:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui video');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -89,16 +120,14 @@ function EditVideo() {
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Video
|
||||
</Title>
|
||||
@@ -130,7 +159,7 @@ function EditVideo() {
|
||||
required
|
||||
/>
|
||||
{embedLink && (
|
||||
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Box mt="sm" pos="relative" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<iframe
|
||||
className="rounded"
|
||||
width="100%"
|
||||
@@ -138,7 +167,27 @@ function EditVideo() {
|
||||
src={embedLink}
|
||||
title="Preview Video"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setFormData({
|
||||
...formData,
|
||||
linkVideo: '',
|
||||
});
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -154,6 +203,17 @@ function EditVideo() {
|
||||
</Box>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -164,7 +224,7 @@ function EditVideo() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Group, Paper, Skeleton, Stack, Text, Tooltip } from '@mantine/core';
|
||||
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';
|
||||
@@ -111,7 +111,6 @@ function DetailVideo() {
|
||||
|
||||
{/* Tombol Aksi */}
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Video" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -124,9 +123,7 @@ function DetailVideo() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Video" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
@@ -138,7 +135,6 @@ function DetailVideo() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -3,6 +3,7 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
|
||||
import stateGallery from '@/app/admin/(dashboard)/_state/desa/gallery';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
@@ -11,9 +12,9 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { IconArrowBack, IconX } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -25,6 +26,7 @@ function CreateVideo() {
|
||||
const router = useRouter();
|
||||
const [link, setLink] = useState('');
|
||||
const embedLink = convertYoutubeUrlToEmbed(link);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
videoState.create.form = {
|
||||
@@ -36,31 +38,37 @@ function CreateVideo() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!embedLink) {
|
||||
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (!embedLink) {
|
||||
toast.error('Link YouTube tidak valid. Pastikan formatnya benar.');
|
||||
return;
|
||||
}
|
||||
|
||||
videoState.create.form.linkVideo = embedLink;
|
||||
await videoState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/gallery/video');
|
||||
videoState.create.form.linkVideo = embedLink;
|
||||
await videoState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/gallery/video');
|
||||
} catch (error) {
|
||||
console.error("Error creating video:", error);
|
||||
toast.error("Terjadi kesalahan saat menambahkan video");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header Back Button + Title */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Video
|
||||
</Title>
|
||||
@@ -80,7 +88,7 @@ function CreateVideo() {
|
||||
<TextInput
|
||||
label="Judul Video"
|
||||
placeholder="Masukkan judul video"
|
||||
defaultValue={videoState.create.form.name}
|
||||
value={videoState.create.form.name}
|
||||
onChange={(e) => {
|
||||
videoState.create.form.name = e.currentTarget.value;
|
||||
}}
|
||||
@@ -91,14 +99,14 @@ function CreateVideo() {
|
||||
<TextInput
|
||||
label="Link Video YouTube"
|
||||
placeholder="https://www.youtube.com/watch?v=abc123"
|
||||
defaultValue={link}
|
||||
value={link}
|
||||
onChange={(e) => setLink(e.currentTarget.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Preview Video */}
|
||||
{embedLink && (
|
||||
<Box mt="sm">
|
||||
<Box mt="sm" pos="relative">
|
||||
<iframe
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
@@ -109,7 +117,24 @@ function CreateVideo() {
|
||||
src={embedLink}
|
||||
title="Preview Video"
|
||||
allowFullScreen
|
||||
></iframe>
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setLink('');
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -128,6 +153,17 @@ function CreateVideo() {
|
||||
|
||||
{/* 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"
|
||||
@@ -138,7 +174,7 @@ function CreateVideo() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -16,8 +16,7 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconDeviceImac, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||
@@ -74,7 +73,6 @@ function ListVideo({ 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 Video</Title>
|
||||
<Tooltip label="Tambah Video Baru" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
@@ -83,7 +81,6 @@ function ListVideo({ search }: { search: string }) {
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table highlightOnHover>
|
||||
|
||||
@@ -6,12 +6,12 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -24,6 +24,16 @@ function EditAjukanPermohonan() {
|
||||
const params = useParams();
|
||||
const stateAjukan = useProxy(stateLayananDesa.ajukanPermohonan);
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
nama: "",
|
||||
nik: "",
|
||||
alamat: "",
|
||||
nomorKk: "",
|
||||
kategoriId: "",
|
||||
});
|
||||
|
||||
// State lokal form
|
||||
const [formData, setFormData] = useState({
|
||||
nama: '',
|
||||
@@ -51,6 +61,13 @@ function EditAjukanPermohonan() {
|
||||
nomorKk: data.nomorKk || '',
|
||||
kategoriId: data.kategoriId || '',
|
||||
});
|
||||
setOriginalData({
|
||||
nama: data.nama || '',
|
||||
nik: data.nik || '',
|
||||
alamat: data.alamat || '',
|
||||
nomorKk: data.nomorKk || '',
|
||||
kategoriId: data.kategoriId || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading ajukan:', error);
|
||||
@@ -69,8 +86,20 @@ function EditAjukanPermohonan() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
nama: originalData.nama,
|
||||
nik: originalData.nik,
|
||||
alamat: originalData.alamat,
|
||||
nomorKk: originalData.nomorKk,
|
||||
kategoriId: originalData.kategoriId,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
stateAjukan.edit.form = {
|
||||
...stateAjukan.edit.form,
|
||||
...formData,
|
||||
@@ -80,6 +109,8 @@ function EditAjukanPermohonan() {
|
||||
} catch (error) {
|
||||
console.error('Error updating ajukan:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui ajukan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -87,11 +118,9 @@ function EditAjukanPermohonan() {
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Back Button */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Ajukan Permohonan
|
||||
</Title>
|
||||
@@ -156,6 +185,17 @@ function EditAjukanPermohonan() {
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -166,7 +206,7 @@ function EditAjukanPermohonan() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
@@ -121,7 +120,6 @@ function DetailAjukanPermohonan() {
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Surat" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -135,9 +133,7 @@ function DetailAjukanPermohonan() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Surat" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
@@ -151,7 +147,6 @@ function DetailAjukanPermohonan() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -33,6 +33,14 @@ function EditPelayananPendudukNonPermanent() {
|
||||
deskripsi: '',
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
});
|
||||
|
||||
|
||||
// Load data sekali dari backend
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
@@ -46,6 +54,10 @@ function EditPelayananPendudukNonPermanent() {
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
});
|
||||
setOriginalData({
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading data:', error);
|
||||
@@ -58,41 +70,55 @@ function EditPelayananPendudukNonPermanent() {
|
||||
|
||||
const handleChange =
|
||||
(field: keyof typeof formData) =>
|
||||
(value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
(value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!statePendudukNonPermanent.findById.data) return;
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (!statePendudukNonPermanent.findById.data) return;
|
||||
|
||||
// Update global state hanya di submit
|
||||
const updated = {
|
||||
...statePendudukNonPermanent.findById.data,
|
||||
name: formData.name,
|
||||
deskripsi: formData.deskripsi,
|
||||
};
|
||||
// Update global state hanya di submit
|
||||
const updated = {
|
||||
...statePendudukNonPermanent.findById.data,
|
||||
name: formData.name,
|
||||
deskripsi: formData.deskripsi,
|
||||
};
|
||||
|
||||
await statePendudukNonPermanent.update.update(updated);
|
||||
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
|
||||
await statePendudukNonPermanent.update.update(updated);
|
||||
router.push('/admin/desa/layanan/pelayanan_penduduk_non_permanent');
|
||||
} catch (error) {
|
||||
console.error('Error updating data:', error);
|
||||
toast.error('Gagal memuat data pelayanan penduduk non permanent');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack gap="xs">
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Pelayanan Penduduk Non Permanent
|
||||
</Title>
|
||||
@@ -130,25 +156,31 @@ function EditPelayananPendudukNonPermanent() {
|
||||
</Box>
|
||||
|
||||
{/* Submit Button */}
|
||||
<Group>
|
||||
<Button
|
||||
bg={colors['blue-button']}
|
||||
onClick={handleSubmit}
|
||||
loading={statePendudukNonPermanent.update.loading}
|
||||
disabled={!formData.name}
|
||||
>
|
||||
{statePendudukNonPermanent.update.loading
|
||||
? 'Menyimpan...'
|
||||
: 'Simpan Perubahan'}
|
||||
</Button>
|
||||
|
||||
<Group justify="right">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={statePendudukNonPermanent.update.loading}
|
||||
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>
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
@@ -51,7 +50,6 @@ function PelayananPendudukNonPermanent() {
|
||||
</Title>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 1 }}>
|
||||
<Tooltip label="Edit Data Pelayanan" withArrow>
|
||||
<Button
|
||||
c="green"
|
||||
variant="light"
|
||||
@@ -65,7 +63,6 @@ function PelayananPendudukNonPermanent() {
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -35,13 +35,21 @@ function EditPelayananPerizinanBerusaha() {
|
||||
link: '',
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [originalData, setOriginalData] = useState({
|
||||
id: '',
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
link: '',
|
||||
});
|
||||
|
||||
// Load data detail
|
||||
useEffect(() => {
|
||||
if (!id) {
|
||||
toast.error("ID tidak valid");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -53,6 +61,12 @@ function EditPelayananPerizinanBerusaha() {
|
||||
deskripsi: data.deskripsi || "",
|
||||
link: data.link || "",
|
||||
});
|
||||
setOriginalData({
|
||||
id: data.id,
|
||||
name: data.name || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
link: data.link || "",
|
||||
});
|
||||
} else {
|
||||
toast.error("Data tidak ditemukan");
|
||||
}
|
||||
@@ -63,10 +77,10 @@ function EditPelayananPerizinanBerusaha() {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadData();
|
||||
}, [id]);
|
||||
|
||||
|
||||
|
||||
const handleChange =
|
||||
(field: keyof typeof formData) =>
|
||||
@@ -77,13 +91,26 @@ function EditPelayananPerizinanBerusaha() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
id: originalData.id,
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
link: originalData.link,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await state.update.update(formData);
|
||||
router.push('/admin/desa/layanan/pelayanan_perizinan_berusaha');
|
||||
} catch (error) {
|
||||
console.error('Error updating pelayanan perizinan berusaha:', error);
|
||||
toast.error('Terjadi kesalahan saat update data');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -100,16 +127,14 @@ function EditPelayananPerizinanBerusaha() {
|
||||
<Stack gap="xs">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Pelayanan Perizinan Berusaha
|
||||
</Title>
|
||||
@@ -150,23 +175,31 @@ function EditPelayananPerizinanBerusaha() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
bg={colors['blue-button']}
|
||||
onClick={handleSubmit}
|
||||
loading={state.update.loading}
|
||||
disabled={!formData.name}
|
||||
>
|
||||
{state.update.loading ? 'Menyimpan...' : 'Simpan Perubahan'}
|
||||
</Button>
|
||||
|
||||
<Group justify="right">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
disabled={state.update.loading}
|
||||
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>
|
||||
|
||||
@@ -16,14 +16,13 @@ import {
|
||||
StepperCompleted,
|
||||
StepperStep,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||
|
||||
function PerizinanBerusaha() {
|
||||
const router = useRouter();
|
||||
@@ -85,7 +84,6 @@ function PerizinanBerusaha() {
|
||||
</Title>
|
||||
</GridCol>
|
||||
<GridCol span={{ base: 12, md: 1 }}>
|
||||
<Tooltip label="Edit Data Perizinan" withArrow>
|
||||
<Button
|
||||
c="green"
|
||||
variant="light"
|
||||
@@ -99,7 +97,6 @@ function PerizinanBerusaha() {
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</GridCol>
|
||||
</Grid>
|
||||
|
||||
|
||||
@@ -1,127 +1,280 @@
|
||||
'use client'
|
||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
||||
'use client';
|
||||
import EditEditor from '@/app/admin/(dashboard)/_com/editEditor';
|
||||
import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} 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, useCallback } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import * as React from 'react';
|
||||
|
||||
// 🔹 Types
|
||||
interface FormData {
|
||||
name: string;
|
||||
deskripsi: string;
|
||||
imageId: string;
|
||||
image2Id: string;
|
||||
imageUrl: string;
|
||||
image2Url: string;
|
||||
}
|
||||
|
||||
interface FileUploaderProps {
|
||||
title: string;
|
||||
file: File | null;
|
||||
setFile: React.Dispatch<React.SetStateAction<File | null>>;
|
||||
preview: string | null;
|
||||
setPreview: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
}
|
||||
|
||||
// 🔹 File Uploader Component
|
||||
const FileUploader: React.FC<FileUploaderProps> = ({
|
||||
title,
|
||||
file,
|
||||
setFile,
|
||||
preview,
|
||||
setPreview
|
||||
}) => {
|
||||
const handleDrop = (files: File[]) => {
|
||||
const selected = files[0];
|
||||
if (selected) {
|
||||
setFile(selected);
|
||||
setPreview(URL.createObjectURL(selected));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemove = () => {
|
||||
setPreview(null);
|
||||
setFile(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
{title}
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleDrop}
|
||||
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 .png, .jpg, .jpeg, .webp
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{preview && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={preview}
|
||||
alt="Preview Gambar"
|
||||
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={handleRemove}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
// 🔹 Main Component
|
||||
function EditSuratKeterangan() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const stateSurat = useProxy(stateLayananDesa.suratKeterangan);
|
||||
|
||||
// state lokal untuk form
|
||||
const [formData, setFormData] = useState({
|
||||
// 🧩 State
|
||||
const [formData, setFormData] = useState<FormData>({
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
imageId: '',
|
||||
image2Id: '',
|
||||
imageUrl: '',
|
||||
image2Url: '',
|
||||
});
|
||||
|
||||
// state file upload
|
||||
const [originalData, setOriginalData] = useState<FormData>(formData);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [file2, setFile2] = useState<File | null>(null);
|
||||
|
||||
// state preview gambar
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [previewImage2, setPreviewImage2] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// load data awal
|
||||
// 🧭 Load Initial Data
|
||||
useEffect(() => {
|
||||
const loadSurat = async () => {
|
||||
const id = params?.id as string;
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const data = await stateSurat.edit.load(id);
|
||||
const data = await stateLayananDesa.suratKeterangan.edit.load(id);
|
||||
if (!data) return;
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
...{
|
||||
name: prev.name || data.name || "",
|
||||
deskripsi: prev.deskripsi || data.deskripsi || "",
|
||||
imageId: prev.imageId || data.imageId || "",
|
||||
image2Id: prev.image2Id || data.image2Id || "",
|
||||
},
|
||||
}));
|
||||
const mapped: FormData = {
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
imageId: data.imageId || '',
|
||||
image2Id: data.image2Id || '',
|
||||
imageUrl: data.image?.link || '',
|
||||
image2Url: data.image2?.link || ''
|
||||
};
|
||||
|
||||
if (data.image?.link && !previewImage) setPreviewImage(data.image.link);
|
||||
if (data.image2?.link && !previewImage2) setPreviewImage2(data.image2.link);
|
||||
setFormData(mapped);
|
||||
setOriginalData(mapped);
|
||||
|
||||
if (data.image?.link) setPreviewImage(data.image.link);
|
||||
if (data.image2?.link) setPreviewImage2(data.image2.link);
|
||||
} catch (error) {
|
||||
console.error("Error loading surat:", error);
|
||||
toast.error("Gagal memuat data surat");
|
||||
console.error('Error loading surat:', error);
|
||||
toast.error('Gagal memuat data surat');
|
||||
}
|
||||
};
|
||||
|
||||
loadSurat();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [params?.id]);
|
||||
|
||||
// 📤 Upload File Helper
|
||||
const uploadFile = async (file: File): Promise<string | null> => {
|
||||
try {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file,
|
||||
name: file.name,
|
||||
});
|
||||
const uploaded = res.data?.data;
|
||||
return uploaded?.id || null;
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 🔁 Reset Form
|
||||
const handleResetForm = () => {
|
||||
setFormData(originalData);
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setPreviewImage2(originalData.image2Url || null);
|
||||
setFile(null);
|
||||
setFile2(null);
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
// handler untuk submit
|
||||
// 💾 Submit Handler
|
||||
const handleSubmit = useCallback(async () => {
|
||||
try {
|
||||
// update form global hanya saat submit
|
||||
stateSurat.edit.form = { ...stateSurat.edit.form, ...formData };
|
||||
setIsSubmitting(true);
|
||||
|
||||
// upload file 1
|
||||
// ✅ Access original state directly (not proxy)
|
||||
const originalState = stateLayananDesa.suratKeterangan;
|
||||
|
||||
// Update form data properties individually
|
||||
originalState.edit.form.name = formData.name;
|
||||
originalState.edit.form.deskripsi = formData.deskripsi;
|
||||
originalState.edit.form.imageId = formData.imageId;
|
||||
originalState.edit.form.image2Id = formData.image2Id;
|
||||
|
||||
// Upload file 1 if exists
|
||||
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');
|
||||
stateSurat.edit.form.imageId = uploaded.id;
|
||||
const uploadedId = await uploadFile(file);
|
||||
if (!uploadedId) {
|
||||
toast.error('Gagal upload gambar pertama');
|
||||
return;
|
||||
}
|
||||
originalState.edit.form.imageId = uploadedId;
|
||||
}
|
||||
|
||||
// upload file 2
|
||||
// Upload file 2 if exists
|
||||
if (file2) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({ file: file2, name: file2.name });
|
||||
const uploaded = res.data?.data;
|
||||
if (!uploaded?.id) return toast.error('Gagal upload gambar');
|
||||
stateSurat.edit.form.image2Id = uploaded.id;
|
||||
const uploadedId = await uploadFile(file2);
|
||||
if (!uploadedId) {
|
||||
toast.error('Gagal upload gambar kedua');
|
||||
return;
|
||||
}
|
||||
originalState.edit.form.image2Id = uploadedId;
|
||||
}
|
||||
|
||||
await stateSurat.edit.update();
|
||||
// Submit update
|
||||
await originalState.edit.update();
|
||||
toast.success('Surat berhasil diperbarui!');
|
||||
router.push('/admin/desa/layanan/pelayanan_surat_keterangan');
|
||||
} catch (error) {
|
||||
console.error('Error updating surat:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui surat');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, file, file2, router, stateSurat.edit]);
|
||||
}, [formData, file, file2, router]);
|
||||
|
||||
// 📝 Form Field Handlers
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormData(prev => ({ ...prev, name: e.target.value }));
|
||||
};
|
||||
|
||||
const handleDeskripsiChange = (html: string) => {
|
||||
setFormData(prev => ({ ...prev, deskripsi: html }));
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Back Button */}
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Surat Keterangan
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
{/* Form */}
|
||||
<Paper
|
||||
w={{ base: '100%', md: '50%' }}
|
||||
bg={colors['white-1']}
|
||||
@@ -131,154 +284,66 @@ function EditSuratKeterangan() {
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Input nama */}
|
||||
{/* Nama Surat */}
|
||||
<TextInput
|
||||
label="Nama Surat Keterangan"
|
||||
placeholder="Masukkan nama surat keterangan"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
|
||||
onChange={handleNameChange}
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Input deskripsi */}
|
||||
{/* Deskripsi */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>
|
||||
Konten
|
||||
</Text>
|
||||
<EditEditor
|
||||
value={formData.deskripsi}
|
||||
onChange={(htmlContent) =>
|
||||
setFormData((prev) => ({ ...prev, deskripsi: htmlContent }))
|
||||
}
|
||||
onChange={handleDeskripsiChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Upload Gambar 1 */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Konten Pelayanan
|
||||
</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 wajib
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
{/* Gambar 1 */}
|
||||
<FileUploader
|
||||
title="Gambar Konten Pelayanan"
|
||||
file={file}
|
||||
setFile={setFile}
|
||||
preview={previewImage}
|
||||
setPreview={setPreviewImage}
|
||||
/>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar 1"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: 'contain',
|
||||
border: `1px solid ${colors['blue-button']}`,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Upload Gambar 2 */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Alur Pelayanan Surat
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setFile2(selectedFile);
|
||||
setPreviewImage2(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 wajib
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{previewImage2 && (
|
||||
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Image
|
||||
src={previewImage2}
|
||||
alt="Preview Gambar 2"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: 'contain',
|
||||
border: `1px solid ${colors['blue-button']}`,
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{/* Gambar 2 */}
|
||||
<FileUploader
|
||||
title="Gambar Alur Pelayanan Surat"
|
||||
file={file2}
|
||||
setFile={setFile2}
|
||||
preview={previewImage2}
|
||||
setPreview={setPreviewImage2}
|
||||
/>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
onClick={handleResetForm}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
boxShadow: '0 4px 15px rgba(79,172,254,0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -287,4 +352,4 @@ function EditSuratKeterangan() {
|
||||
);
|
||||
}
|
||||
|
||||
export default EditSuratKeterangan;
|
||||
export default EditSuratKeterangan;
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
@@ -142,7 +141,6 @@ function DetailSuratKeterangan() {
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Surat" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -156,9 +154,7 @@ function DetailSuratKeterangan() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Surat" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
@@ -172,7 +168,6 @@ function DetailSuratKeterangan() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -5,16 +5,17 @@ import stateLayananDesa from '@/app/admin/(dashboard)/_state/desa/layananDesa';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
@@ -28,6 +29,7 @@ function CreateSuratKeterangan() {
|
||||
const [previewImage2, setPreviewImage2] = useState<{ preview: string; file: File } | null>(null);
|
||||
const [previewImage, setPreviewImage] = useState<{ preview: string; file: File } | null>(null);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
stateSurat.create.form = {
|
||||
@@ -46,6 +48,7 @@ function CreateSuratKeterangan() {
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Upload gambar utama
|
||||
const res1 = await ApiFetch.api.fileStorage.create.post({
|
||||
file: previewImage.file,
|
||||
@@ -78,6 +81,8 @@ function CreateSuratKeterangan() {
|
||||
} catch (error) {
|
||||
console.error('Error creating surat keterangan:', error);
|
||||
toast.error('Terjadi kesalahan saat menambahkan surat keterangan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -85,11 +90,9 @@ function CreateSuratKeterangan() {
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Surat Keterangan
|
||||
</Title>
|
||||
@@ -106,7 +109,7 @@ function CreateSuratKeterangan() {
|
||||
<Stack gap="md">
|
||||
{/* Nama Surat */}
|
||||
<TextInput
|
||||
defaultValue={stateSurat.create.form.name}
|
||||
value={stateSurat.create.form.name}
|
||||
onChange={(val) => (stateSurat.create.form.name = val.target.value)}
|
||||
label="Nama Surat Keterangan"
|
||||
placeholder="Masukkan nama surat keterangan"
|
||||
@@ -143,7 +146,7 @@ function CreateSuratKeterangan() {
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': [] }}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
@@ -164,7 +167,7 @@ function CreateSuratKeterangan() {
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage.preview}
|
||||
alt="Preview Gambar Utama"
|
||||
@@ -172,6 +175,23 @@ function CreateSuratKeterangan() {
|
||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setPreviewImage(null);
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -193,7 +213,7 @@ function CreateSuratKeterangan() {
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': [] }}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
@@ -214,7 +234,7 @@ function CreateSuratKeterangan() {
|
||||
</Dropzone>
|
||||
|
||||
{previewImage2 ? (
|
||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage2.preview}
|
||||
alt="Preview Gambar Tambahan"
|
||||
@@ -222,6 +242,23 @@ function CreateSuratKeterangan() {
|
||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => {
|
||||
setPreviewImage2(null);
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed" mt="sm" ta="center">
|
||||
@@ -232,6 +269,17 @@ function CreateSuratKeterangan() {
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -242,7 +290,7 @@ function CreateSuratKeterangan() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -82,7 +81,6 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
||||
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>List Surat Keterangan</Title>
|
||||
<Tooltip label="Tambah Surat Keterangan" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
@@ -93,7 +91,6 @@ function ListSuratKeterangan({ search }: { search: string }) {
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Box style={{ overflowX: "auto" }}>
|
||||
<Table highlightOnHover>
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -22,6 +22,7 @@ function EditPelayananTelunjukSakti() {
|
||||
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
@@ -29,6 +30,12 @@ function EditPelayananTelunjukSakti() {
|
||||
link: '',
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
link: '',
|
||||
});
|
||||
|
||||
// Load data awal hanya sekali (pas ada id)
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
@@ -43,6 +50,11 @@ function EditPelayananTelunjukSakti() {
|
||||
deskripsi: data.deskripsi ?? '',
|
||||
link: data.link ?? '',
|
||||
});
|
||||
setOriginalData({
|
||||
name: data.name ?? '',
|
||||
deskripsi: data.deskripsi ?? '',
|
||||
link: data.link ?? '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading pelayanan telunjuk sakti:', error);
|
||||
@@ -61,9 +73,19 @@ function EditPelayananTelunjukSakti() {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
link: originalData.link,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
// Submit: update global state hanya saat simpan
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
stateTelunjukDesa.edit.form = {
|
||||
...stateTelunjukDesa.edit.form,
|
||||
...formData,
|
||||
@@ -74,6 +96,8 @@ function EditPelayananTelunjukSakti() {
|
||||
} catch (error) {
|
||||
console.error('Error updating pelayanan telunjuk sakti:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui pelayanan telunjuk sakti');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -81,11 +105,9 @@ function EditPelayananTelunjukSakti() {
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Back Button + Title */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Pelayanan Telunjuk Sakti Desa
|
||||
</Title>
|
||||
@@ -128,6 +150,17 @@ function EditPelayananTelunjukSakti() {
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -138,7 +171,7 @@ function EditPelayananTelunjukSakti() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
@@ -130,7 +129,6 @@ function DetailPelayananTelunjukSakti() {
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Layanan" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -144,9 +142,7 @@ function DetailPelayananTelunjukSakti() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Layanan" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
@@ -160,7 +156,6 @@ function DetailPelayananTelunjukSakti() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -6,20 +6,22 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
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 CreatePelayananTelunjukDesa() {
|
||||
const stateTelunjukDesa = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
stateTelunjukDesa.create.form = {
|
||||
@@ -31,6 +33,7 @@ function CreatePelayananTelunjukDesa() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await stateTelunjukDesa.create.create();
|
||||
resetForm();
|
||||
toast.success('Data pelayanan telunjuk sakti berhasil ditambahkan');
|
||||
@@ -38,6 +41,8 @@ function CreatePelayananTelunjukDesa() {
|
||||
} catch (error) {
|
||||
console.error('Error create pelayanan telunjuk sakti:', error);
|
||||
toast.error('Terjadi kesalahan saat menambahkan data');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,11 +50,9 @@ function CreatePelayananTelunjukDesa() {
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Tambah Pelayanan Telunjuk Sakti Desa
|
||||
</Title>
|
||||
@@ -67,7 +70,7 @@ function CreatePelayananTelunjukDesa() {
|
||||
<Stack gap="md">
|
||||
{/* Nama */}
|
||||
<TextInput
|
||||
defaultValue={stateTelunjukDesa.create.form.name}
|
||||
value={stateTelunjukDesa.create.form.name}
|
||||
onChange={(val) => {
|
||||
stateTelunjukDesa.create.form.name = val.target.value;
|
||||
}}
|
||||
@@ -78,7 +81,7 @@ function CreatePelayananTelunjukDesa() {
|
||||
|
||||
{/* Deskripsi */}
|
||||
<TextInput
|
||||
defaultValue={stateTelunjukDesa.create.form.deskripsi}
|
||||
value={stateTelunjukDesa.create.form.deskripsi}
|
||||
onChange={(val) => {
|
||||
stateTelunjukDesa.create.form.deskripsi = val.target.value;
|
||||
}}
|
||||
@@ -89,7 +92,7 @@ function CreatePelayananTelunjukDesa() {
|
||||
|
||||
{/* Link */}
|
||||
<TextInput
|
||||
defaultValue={stateTelunjukDesa.create.form.link}
|
||||
value={stateTelunjukDesa.create.form.link}
|
||||
onChange={(val) => {
|
||||
stateTelunjukDesa.create.form.link = val.target.value;
|
||||
}}
|
||||
@@ -100,6 +103,17 @@ function CreatePelayananTelunjukDesa() {
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -110,7 +124,7 @@ function CreatePelayananTelunjukDesa() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,159 +1,3 @@
|
||||
// /* eslint-disable react-hooks/exhaustive-deps */
|
||||
// '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 { IconDeviceImac, IconSearch } from '@tabler/icons-react';
|
||||
// import { useRouter } from 'next/navigation';
|
||||
// import { useEffect, useMemo, useState } from 'react';
|
||||
// import { useProxy } from 'valtio/utils';
|
||||
// import HeaderSearch from '../../../_com/header';
|
||||
// import JudulList from '../../../_com/judulList';
|
||||
// import stateLayananDesa from '../../../_state/desa/layananDesa';
|
||||
|
||||
// function PelayananTelunjukSakti() {
|
||||
// const [search, setSearch] = useState("");
|
||||
// return (
|
||||
// <Box>
|
||||
// <HeaderSearch
|
||||
// title='Posisi Organisasi'
|
||||
// placeholder='pencarian'
|
||||
// searchIcon={<IconSearch size={20} />}
|
||||
// value={search}
|
||||
// onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
// />
|
||||
// <ListPelayananTelunjukSakti search={search} />
|
||||
// </Box>
|
||||
// );
|
||||
// }
|
||||
|
||||
// function ListPelayananTelunjukSakti({ search }: { search: string }) {
|
||||
// const telunjukSaktiState = useProxy(stateLayananDesa.pelayananTelunjukSaktiDesa)
|
||||
// const router = useRouter()
|
||||
|
||||
// const {
|
||||
// data,
|
||||
// page,
|
||||
// totalPages,
|
||||
// loading,
|
||||
// load,
|
||||
// } = telunjukSaktiState.findMany;
|
||||
|
||||
// useEffect(() => {
|
||||
// load(page, 10)
|
||||
// }, [])
|
||||
|
||||
// const filteredData = useMemo(() => {
|
||||
// if (!data) return [];
|
||||
// return data.filter(item => {
|
||||
// const keyword = search.toLowerCase();
|
||||
// return (
|
||||
// item.name?.toLowerCase().includes(keyword) ||
|
||||
// item.link?.toLowerCase().includes(keyword) ||
|
||||
// item.deskripsi?.toLowerCase().includes(keyword)
|
||||
// );
|
||||
// })
|
||||
// .sort((a, b) => a.posisi?.hierarki - b.posisi?.hierarki);
|
||||
// }, [data, search]);
|
||||
|
||||
// if (loading || !data) {
|
||||
// return (
|
||||
// <Stack py={10}>
|
||||
// <Skeleton height={300} />
|
||||
// </Stack>
|
||||
// );
|
||||
// }
|
||||
|
||||
// if (data.length === 0) {
|
||||
// return (
|
||||
// <Box py={10}>
|
||||
// <Paper bg={colors['white-1']} p={'md'}>
|
||||
// <JudulList
|
||||
// title='List Pelayanan Telunjuk Sakti Desa'
|
||||
// href='/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create'
|
||||
// />
|
||||
// <Table striped withTableBorder withRowBorders>
|
||||
// <TableThead>
|
||||
// <TableTr>
|
||||
// <TableTh>Nama</TableTh>
|
||||
// <TableTh>Link</TableTh>
|
||||
// <TableTh>Detail</TableTh>
|
||||
// </TableTr>
|
||||
// </TableThead>
|
||||
// <TableTbody>
|
||||
// <TableTr>
|
||||
// <TableTd colSpan={3}>
|
||||
// <Text fz={"sm"} color="gray.5">
|
||||
// Tidak ada data
|
||||
// </Text>
|
||||
// </TableTd>
|
||||
// </TableTr>
|
||||
// </TableTbody>
|
||||
// </Table>
|
||||
// </Paper>
|
||||
// </Box>
|
||||
// );
|
||||
// }
|
||||
|
||||
// return (
|
||||
// <Box py={10}>
|
||||
// <Paper bg={colors['white-1']} p={'md'}>
|
||||
// <JudulList
|
||||
// title='List Pelayanan Telunjuk Sakti Desa'
|
||||
// href='/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/create'
|
||||
// />
|
||||
// <Table striped withTableBorder withRowBorders>
|
||||
// <TableThead>
|
||||
// <TableTr>
|
||||
// <TableTh>Nama</TableTh>
|
||||
// <TableTh>Link</TableTh>
|
||||
// <TableTh>Detail</TableTh>
|
||||
// </TableTr>
|
||||
// </TableThead>
|
||||
// <TableTbody>
|
||||
// {filteredData.map((item) => (
|
||||
// <TableTr key={item.id}>
|
||||
// <TableTd>
|
||||
// <Box w={100}>
|
||||
// <Text truncate="end" lineClamp={1} fz={"sm"} dangerouslySetInnerHTML={{ __html: item.name }} />
|
||||
// </Box>
|
||||
// </TableTd>
|
||||
// <TableTd>
|
||||
// <Box w={100}>
|
||||
// <a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||
// <Text dangerouslySetInnerHTML={{ __html: item.deskripsi }} truncate="end" fz={"sm"} />
|
||||
// </a>
|
||||
// </Box>
|
||||
// </TableTd>
|
||||
// <TableTd>
|
||||
// <Text>
|
||||
// <Button onClick={() => router.push(`/admin/desa/layanan/pelayanan_telunjuk_sakti_desa/${item.id}`)}>
|
||||
// <IconDeviceImac size={20} />
|
||||
// </Button>
|
||||
// </Text>
|
||||
// </TableTd>
|
||||
// </TableTr>
|
||||
// ))}
|
||||
// </TableTbody>
|
||||
// </Table>
|
||||
// </Paper>
|
||||
// <Center>
|
||||
// <Pagination
|
||||
// value={page}
|
||||
// onChange={(newPage) => {
|
||||
// load(newPage, 10);
|
||||
// window.scrollTo(0, 0);
|
||||
// }}
|
||||
// total={totalPages}
|
||||
// mt="md"
|
||||
// mb="md"
|
||||
// />
|
||||
// </Center>
|
||||
// </Box>
|
||||
// );
|
||||
// }
|
||||
|
||||
// export default PelayananTelunjukSakti;
|
||||
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
|
||||
@@ -174,8 +18,7 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -225,7 +68,6 @@ function ListPelayananTelunjukSakti({ 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 Pelayanan Telunjuk Sakti</Title>
|
||||
<Tooltip label="Tambah Layanan" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
@@ -236,7 +78,6 @@ function ListPelayananTelunjukSakti({ search }: { search: string }) {
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover style={{ minWidth: '700px' }}>
|
||||
|
||||
@@ -5,16 +5,17 @@ import penghargaanState from '@/app/admin/(dashboard)/_state/desa/penghargaan';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
@@ -30,6 +31,15 @@ function EditPenghargaan() {
|
||||
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
name: "",
|
||||
juara: "",
|
||||
deskripsi: "",
|
||||
imageId: "",
|
||||
imageUrl: "",
|
||||
});
|
||||
|
||||
// Lokal formData
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -47,17 +57,25 @@ function EditPenghargaan() {
|
||||
|
||||
try {
|
||||
const data = await statePenghargaan.edit.load(id);
|
||||
|
||||
if (data) {
|
||||
setFormData({
|
||||
name: data.name || '',
|
||||
juara: data.juara || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
imageId: data.imageId || '',
|
||||
const newForm = {
|
||||
name: data.name || "",
|
||||
juara: data.juara || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
imageId: data.imageId || "",
|
||||
};
|
||||
setFormData(newForm);
|
||||
|
||||
// simpan juga versi original
|
||||
const imageUrl = data.image?.link || "";
|
||||
|
||||
setOriginalData({
|
||||
...newForm,
|
||||
imageUrl: imageUrl,
|
||||
});
|
||||
|
||||
if (data?.image?.link) {
|
||||
setPreviewImage(data.image.link);
|
||||
}
|
||||
setPreviewImage(imageUrl || null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading penghargaan:', error);
|
||||
@@ -68,33 +86,49 @@ function EditPenghargaan() {
|
||||
loadPenghargaan();
|
||||
}, [params?.id]);
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
juara: originalData.juara,
|
||||
deskripsi: originalData.deskripsi,
|
||||
imageId: originalData.imageId,
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
// Submit
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Sync ke global state saat submit
|
||||
statePenghargaan.edit.form = {
|
||||
...statePenghargaan.edit.form,
|
||||
...formData,
|
||||
};
|
||||
|
||||
// Upload file baru (kalau ada)
|
||||
let imageId = formData.imageId;
|
||||
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');
|
||||
return toast.error("Gagal upload gambar");
|
||||
}
|
||||
|
||||
statePenghargaan.edit.form.imageId = uploaded.id;
|
||||
imageId = uploaded.id;
|
||||
}
|
||||
|
||||
// Update global state form (baru di sini)
|
||||
statePenghargaan.edit.form = {
|
||||
...statePenghargaan.edit.form,
|
||||
name: formData.name,
|
||||
juara: formData.juara,
|
||||
deskripsi: formData.deskripsi,
|
||||
imageId,
|
||||
}
|
||||
await statePenghargaan.edit.update();
|
||||
toast.success('Penghargaan berhasil diperbarui!');
|
||||
router.push('/admin/desa/penghargaan');
|
||||
} catch (error) {
|
||||
console.error('Error updating penghargaan:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui penghargaan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -102,11 +136,9 @@ function EditPenghargaan() {
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Tombol Back + Title */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Penghargaan
|
||||
</Title>
|
||||
@@ -155,7 +187,7 @@ function EditPenghargaan() {
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': [] }}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
@@ -174,25 +206,47 @@ function EditPenghargaan() {
|
||||
Seret gambar atau klik untuk memilih file
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Maksimal 5MB, format gambar wajib
|
||||
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 220,
|
||||
objectFit: 'contain',
|
||||
border: `1px solid ${colors['blue-button']}`,
|
||||
<Box pos="relative" mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Box>
|
||||
<Image
|
||||
src={previewImage.startsWith('http') ? previewImage : `${window.location.origin}${previewImage}`}
|
||||
alt="Preview Gambar"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
objectFit: 'contain',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 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);
|
||||
}}
|
||||
loading="lazy"
|
||||
/>
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
@@ -212,6 +266,17 @@ function EditPenghargaan() {
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -222,7 +287,7 @@ function EditPenghargaan() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react';
|
||||
import penghargaanState from '../../../_state/desa/penghargaan';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
@@ -12,12 +8,15 @@ import {
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import colors from '@/con/colors';
|
||||
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';
|
||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||
import penghargaanState from '../../../_state/desa/penghargaan';
|
||||
|
||||
function DetailPenghargaan() {
|
||||
const statePenghargaan = useProxy(penghargaanState);
|
||||
@@ -127,34 +126,30 @@ function DetailPenghargaan() {
|
||||
</Box>
|
||||
|
||||
<Group gap="sm" mt={10}>
|
||||
<Tooltip label="Hapus Penghargaan" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setSelectedId(data.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setSelectedId(data.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
|
||||
<Tooltip label="Edit Penghargaan" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
router.push(`/admin/desa/penghargaan/${data.id}/edit`)
|
||||
}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
router.push(`/admin/desa/penghargaan/${data.id}/edit`)
|
||||
}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
@@ -27,6 +28,7 @@ function CreatePenghargaan() {
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
statePenghargaan.create.form = {
|
||||
@@ -40,37 +42,43 @@ function CreatePenghargaan() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!file) {
|
||||
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
||||
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');
|
||||
}
|
||||
|
||||
statePenghargaan.create.form.imageId = uploaded.id;
|
||||
|
||||
await statePenghargaan.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/penghargaan');
|
||||
} catch (error) {
|
||||
console.error('Error creating penghargaan:', error);
|
||||
toast.error('Terjadi kesalahan saat menambahkan penghargaan');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
statePenghargaan.create.form.imageId = uploaded.id;
|
||||
|
||||
await statePenghargaan.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/penghargaan');
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Penghargaan
|
||||
</Title>
|
||||
@@ -87,7 +95,7 @@ function CreatePenghargaan() {
|
||||
>
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
defaultValue={statePenghargaan.create.form.name}
|
||||
value={statePenghargaan.create.form.name}
|
||||
onChange={(val) => (statePenghargaan.create.form.name = val.target.value)}
|
||||
label="Nama Penghargaan"
|
||||
placeholder="Masukkan nama penghargaan"
|
||||
@@ -95,7 +103,7 @@ function CreatePenghargaan() {
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
defaultValue={statePenghargaan.create.form.juara}
|
||||
value={statePenghargaan.create.form.juara}
|
||||
onChange={(val) => (statePenghargaan.create.form.juara = val.target.value)}
|
||||
label="Juara"
|
||||
placeholder="Masukkan juara"
|
||||
@@ -125,7 +133,7 @@ function CreatePenghargaan() {
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': [] }}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="xl"
|
||||
>
|
||||
@@ -146,7 +154,7 @@ function CreatePenghargaan() {
|
||||
</Dropzone>
|
||||
|
||||
{previewImage && (
|
||||
<Box mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Box pos={"relative"} mt="sm" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar"
|
||||
@@ -154,12 +162,41 @@ function CreatePenghargaan() {
|
||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||
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>
|
||||
|
||||
{/* 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"
|
||||
@@ -170,7 +207,7 @@ function CreatePenghargaan() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -18,8 +18,7 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
@@ -69,7 +68,6 @@ function ListPenghargaan({ search }: { search: string }) {
|
||||
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>List Penghargaan</Title>
|
||||
<Tooltip label="Tambah Penghargaan" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
@@ -78,7 +76,6 @@ function ListPenghargaan({ search }: { search: string }) {
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconCategory, IconListDetails } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { IconListDetails, IconCategory } from '@tabler/icons-react';
|
||||
|
||||
function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter()
|
||||
@@ -14,15 +14,13 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
label: "List Pengumuman",
|
||||
value: "listpengumuman",
|
||||
href: "/admin/desa/pengumuman/list-pengumuman",
|
||||
icon: <IconListDetails size={18} stroke={1.8} />,
|
||||
tooltip: "Lihat semua daftar pengumuman"
|
||||
icon: <IconListDetails size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Kategori Pengumuman",
|
||||
value: "kategoripengumuman",
|
||||
href: "/admin/desa/pengumuman/kategori-pengumuman",
|
||||
icon: <IconCategory size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola kategori pengumuman"
|
||||
icon: <IconCategory size={18} stroke={1.8} />
|
||||
},
|
||||
];
|
||||
|
||||
@@ -70,19 +68,18 @@ function LayoutTabsLayanan({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<Tooltip key={i} label={tab.tooltip} position="bottom" withArrow transitionProps={{ transition: 'pop', duration: 200 }}>
|
||||
<TabsTab
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
</Tooltip>
|
||||
<TabsTab
|
||||
key={i}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -23,8 +23,9 @@ function EditKategoriPengumuman() {
|
||||
const editState = useProxy(stateDesaPengumuman.category);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({ name: '' });
|
||||
const [originalData, setOriginalData] = useState({ name: '' });
|
||||
|
||||
// Load data awal sekali aja
|
||||
useEffect(() => {
|
||||
@@ -36,6 +37,7 @@ function EditKategoriPengumuman() {
|
||||
const data = await editState.update.load(id);
|
||||
if (data) {
|
||||
setFormData({ name: data.name || '' });
|
||||
setOriginalData({ name: data.name || '' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading kategori Pengumuman:', error);
|
||||
@@ -55,6 +57,7 @@ function EditKategoriPengumuman() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Update global state hanya di sini
|
||||
editState.update.form = {
|
||||
...editState.update.form,
|
||||
@@ -67,14 +70,23 @@ function EditKategoriPengumuman() {
|
||||
} catch (error) {
|
||||
console.error('Error updating kategori Pengumuman:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui kategori Pengumuman');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
name: originalData.name,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
@@ -83,7 +95,6 @@ function EditKategoriPengumuman() {
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Kategori Pengumuman
|
||||
</Title>
|
||||
@@ -108,6 +119,17 @@ function EditKategoriPengumuman() {
|
||||
/>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -118,7 +140,7 @@ function EditKategoriPengumuman() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -9,15 +9,18 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
function CreateKategoriPengumuman() {
|
||||
const createState = useProxy(stateDesaPengumuman.category);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
createState.create.form = {
|
||||
@@ -26,16 +29,22 @@ function CreateKategoriPengumuman() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await createState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/pengumuman/kategori-pengumuman');
|
||||
try {
|
||||
await createState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/pengumuman/kategori-pengumuman');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Gagal menambahkan kategori pengumuman');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header dengan back button */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
@@ -44,7 +53,6 @@ function CreateKategoriPengumuman() {
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Tambah Kategori Pengumuman
|
||||
</Title>
|
||||
@@ -63,12 +71,23 @@ function CreateKategoriPengumuman() {
|
||||
<TextInput
|
||||
label="Nama Kategori Pengumuman"
|
||||
placeholder="Masukkan nama kategori pengumuman"
|
||||
defaultValue={createState.create.form.name || ''}
|
||||
value={createState.create.form.name || ''}
|
||||
onChange={(e) => (createState.create.form.name = 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"
|
||||
@@ -79,7 +98,7 @@ function CreateKategoriPengumuman() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box, Button, Center, Paper, Skeleton, Stack,
|
||||
Box, Button, Center,
|
||||
Pagination,
|
||||
Paper, Skeleton, Stack,
|
||||
Table, TableTbody, TableTd, TableTh, TableThead, TableTr,
|
||||
Text, Title, Tooltip, Pagination
|
||||
Text, Title
|
||||
} from '@mantine/core';
|
||||
import { IconEdit, IconSearch, IconTrash, IconPlus } from '@tabler/icons-react';
|
||||
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
@@ -66,16 +68,14 @@ function ListKategoriPengumuman({ search }: { search: string }) {
|
||||
<Stack>
|
||||
<Box style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}>
|
||||
<Title order={4}>List Kategori Pengumuman</Title>
|
||||
<Tooltip label="Tambah Kategori Pengumuman" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/pengumuman/kategori-pengumuman/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/pengumuman/kategori-pengumuman/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
@@ -99,29 +99,25 @@ function ListKategoriPengumuman({ search }: { search: string }) {
|
||||
<Text truncate lineClamp={1}>{item.name}</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Tooltip label="Edit Kategori Pengumuman" withArrow>
|
||||
<Button
|
||||
variant='light'
|
||||
color='green'
|
||||
onClick={() => router.push(`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`)}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant='light'
|
||||
color='green'
|
||||
onClick={() => router.push(`/admin/desa/pengumuman/kategori-pengumuman/${item.id}`)}
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Tooltip label="Hapus Kategori Pengumuman" withArrow>
|
||||
<Button
|
||||
variant='light'
|
||||
color='red'
|
||||
disabled={listDataState.delete.loading}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id)
|
||||
setModalHapus(true)
|
||||
}}>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant='light'
|
||||
color='red'
|
||||
disabled={listDataState.delete.loading}
|
||||
onClick={() => {
|
||||
setSelectedId(item.id)
|
||||
setModalHapus(true)
|
||||
}}>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader
|
||||
} from "@mantine/core";
|
||||
import { IconArrowBack } from "@tabler/icons-react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
@@ -34,6 +34,15 @@ function EditPengumuman() {
|
||||
content: "",
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
categoryPengumumanId: "",
|
||||
content: "",
|
||||
});
|
||||
|
||||
// Load kategori & pengumuman by id saat pertama kali
|
||||
useEffect(() => {
|
||||
editState.category.findMany.load();
|
||||
@@ -51,6 +60,12 @@ function EditPengumuman() {
|
||||
categoryPengumumanId: data.categoryPengumumanId || "",
|
||||
content: data.content || "",
|
||||
});
|
||||
setOriginalData({
|
||||
judul: data.judul || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
categoryPengumumanId: data.categoryPengumumanId || "",
|
||||
content: data.content || "",
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading pengumuman:", error);
|
||||
@@ -67,6 +82,7 @@ function EditPengumuman() {
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// update global state hanya sekali pas submit
|
||||
editState.pengumuman.edit.form = {
|
||||
...editState.pengumuman.edit.form,
|
||||
@@ -79,22 +95,32 @@ function EditPengumuman() {
|
||||
} catch (error) {
|
||||
console.error("Error updating pengumuman:", error);
|
||||
toast.error("Terjadi kesalahan saat memperbarui pengumuman");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
judul: originalData.judul,
|
||||
deskripsi: originalData.deskripsi,
|
||||
categoryPengumumanId: originalData.categoryPengumumanId,
|
||||
content: originalData.content,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: "sm", md: "lg" }} py="md">
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors["blue-button"]} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Pengumuman
|
||||
</Title>
|
||||
@@ -155,17 +181,29 @@ function EditPengumuman() {
|
||||
</Box>
|
||||
|
||||
<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)",
|
||||
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
|
||||
color: '#fff',
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
@@ -8,16 +10,13 @@ import {
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
Text
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirmasiHapus';
|
||||
import stateDesaPengumuman from '@/app/admin/(dashboard)/_state/desa/pengumuman';
|
||||
|
||||
export default function DetailPengumuman() {
|
||||
const pengumumanState = useProxy(stateDesaPengumuman);
|
||||
@@ -117,7 +116,6 @@ export default function DetailPengumuman() {
|
||||
</Box>
|
||||
|
||||
<Group gap="sm">
|
||||
<Tooltip label="Hapus Pengumuman" withArrow position="top">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
@@ -130,9 +128,7 @@ export default function DetailPengumuman() {
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Edit Pengumuman" withArrow position="top">
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() =>
|
||||
@@ -146,7 +142,6 @@ export default function DetailPengumuman() {
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -13,25 +13,36 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
Loader
|
||||
} 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 CreatePengumuman() {
|
||||
const pengumumanState = useProxy(stateDesaPengumuman);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useShallowEffect(() => {
|
||||
pengumumanState.category.findMany.load();
|
||||
}, []);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await pengumumanState.pengumuman.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/pengumuman/list-pengumuman');
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await pengumumanState.pengumuman.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/pengumuman/list-pengumuman');
|
||||
} catch (error) {
|
||||
console.error('Error creating pengumuman:', error);
|
||||
toast.error('Terjadi kesalahan saat membuat pengumuman');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
@@ -47,11 +58,9 @@ function CreatePengumuman() {
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Pengumuman
|
||||
</Title>
|
||||
@@ -68,7 +77,7 @@ function CreatePengumuman() {
|
||||
<Stack gap="md">
|
||||
{/* Judul */}
|
||||
<TextInput
|
||||
defaultValue={pengumumanState.pengumuman.create.form.judul}
|
||||
value={pengumumanState.pengumuman.create.form.judul}
|
||||
onChange={(val) => (pengumumanState.pengumuman.create.form.judul = val.target.value)}
|
||||
label="Judul"
|
||||
placeholder="Masukkan judul pengumuman"
|
||||
@@ -79,21 +88,32 @@ function CreatePengumuman() {
|
||||
<Select
|
||||
label="Kategori"
|
||||
placeholder="Pilih kategori"
|
||||
value={pengumumanState.pengumuman.create.form.categoryPengumumanId || ""}
|
||||
onChange={(val) => {
|
||||
pengumumanState.pengumuman.create.form.categoryPengumumanId = val ?? "";
|
||||
}}
|
||||
data={pengumumanState.category.findMany.data?.map((item) => ({
|
||||
label: item.name,
|
||||
value: item.id,
|
||||
}))}
|
||||
})) || []}
|
||||
value={pengumumanState.pengumuman.create.form.categoryPengumumanId || null}
|
||||
onChange={(val: string | null) => {
|
||||
if (val) {
|
||||
const selected = pengumumanState.category.findMany.data?.find(
|
||||
(item) => item.id === val
|
||||
);
|
||||
if (selected) {
|
||||
pengumumanState.pengumuman.create.form.categoryPengumumanId = selected.id;
|
||||
}
|
||||
} else {
|
||||
pengumumanState.pengumuman.create.form.categoryPengumumanId = '';
|
||||
}
|
||||
}}
|
||||
searchable
|
||||
clearable
|
||||
nothingFoundMessage="Tidak ditemukan"
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Deskripsi Singkat */}
|
||||
<TextInput
|
||||
defaultValue={pengumumanState.pengumuman.create.form.deskripsi}
|
||||
value={pengumumanState.pengumuman.create.form.deskripsi}
|
||||
onChange={(val) => (pengumumanState.pengumuman.create.form.deskripsi = val.target.value)}
|
||||
label="Deskripsi Singkat"
|
||||
placeholder="Masukkan deskripsi singkat"
|
||||
@@ -115,6 +135,17 @@ function CreatePengumuman() {
|
||||
|
||||
{/* Tombol Submit */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -125,7 +156,7 @@ function CreatePengumuman() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -17,8 +17,7 @@ import {
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconCircleDashedPlus, IconDeviceImacCog, IconSearch } from '@tabler/icons-react';
|
||||
@@ -69,16 +68,14 @@ function ListPengumuman({ 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 Pengumuman</Title>
|
||||
<Tooltip label="Tambah Pengumuman" withArrow>
|
||||
<Button
|
||||
leftSection={<IconCircleDashedPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/pengumuman/list-pengumuman/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
leftSection={<IconCircleDashedPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/desa/pengumuman/list-pengumuman/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover style={{ minWidth: '700px' }}>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title, Tooltip } from '@mantine/core';
|
||||
import { ScrollArea, Stack, Tabs, TabsList, TabsPanel, TabsTab, Title } from '@mantine/core';
|
||||
import { IconCategory, IconListCheck } from '@tabler/icons-react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
@@ -14,15 +14,13 @@ function LayoutTabsPotensi({ children }: { children: React.ReactNode }) {
|
||||
label: "List Potensi",
|
||||
value: "list_potensi",
|
||||
href: "/admin/desa/potensi/list-potensi",
|
||||
icon: <IconListCheck size={18} stroke={1.8} />,
|
||||
tooltip: "Lihat semua potensi desa"
|
||||
icon: <IconListCheck size={18} stroke={1.8} />
|
||||
},
|
||||
{
|
||||
label: "Kategori Potensi",
|
||||
value: "kategori_potensi",
|
||||
href: "/admin/desa/potensi/kategori-potensi",
|
||||
icon: <IconCategory size={18} stroke={1.8} />,
|
||||
tooltip: "Kelola kategori potensi"
|
||||
icon: <IconCategory size={18} stroke={1.8} />
|
||||
},
|
||||
];
|
||||
|
||||
@@ -70,19 +68,18 @@ function LayoutTabsPotensi({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
>
|
||||
{tabs.map((tab, i) => (
|
||||
<Tooltip key={i} label={tab.tooltip} position="bottom" withArrow transitionProps={{ transition: 'pop', duration: 200 }}>
|
||||
<TabsTab
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
</Tooltip>
|
||||
<TabsTab
|
||||
key={i}
|
||||
value={tab.value}
|
||||
leftSection={tab.icon}
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
fontSize: "0.9rem",
|
||||
transition: "all 0.2s ease",
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</TabsTab>
|
||||
))}
|
||||
</TabsList>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
@@ -27,6 +27,12 @@ function EditKategoriPotensi() {
|
||||
nama: '',
|
||||
});
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
nama: '',
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Load data dari backend -> isi ke formData lokal
|
||||
useEffect(() => {
|
||||
const loadKategori = async () => {
|
||||
@@ -39,6 +45,9 @@ function EditKategoriPotensi() {
|
||||
setFormData({
|
||||
nama: data.nama || '',
|
||||
});
|
||||
setOriginalData({
|
||||
nama: data.nama || '',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading kategori potensi:', error);
|
||||
@@ -56,8 +65,16 @@ function EditKategoriPotensi() {
|
||||
}));
|
||||
};
|
||||
|
||||
const handleResetForm = () => {
|
||||
setFormData({
|
||||
nama: originalData.nama,
|
||||
});
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Update global state hanya pas submit
|
||||
editState.update.form = {
|
||||
...editState.update.form,
|
||||
@@ -70,13 +87,14 @@ function EditKategoriPotensi() {
|
||||
} catch (error) {
|
||||
console.error('Error updating kategori potensi:', error);
|
||||
toast.error('Terjadi kesalahan saat memperbarui kategori potensi');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
@@ -85,7 +103,6 @@ function EditKategoriPotensi() {
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Edit Kategori Potensi
|
||||
</Title>
|
||||
@@ -109,6 +126,17 @@ function EditKategoriPotensi() {
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={handleResetForm}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
@@ -119,7 +147,7 @@ function EditKategoriPotensi() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -9,15 +9,17 @@ import {
|
||||
Stack,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip
|
||||
Loader
|
||||
} from '@mantine/core';
|
||||
import { IconArrowBack } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
function CreateKategoriPotensi() {
|
||||
const createState = useProxy(potensiDesaState.kategoriPotensi);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const resetForm = () => {
|
||||
createState.create.form = {
|
||||
@@ -26,25 +28,30 @@ function CreateKategoriPotensi() {
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
await createState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/potensi/kategori-potensi');
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
await createState.create.create();
|
||||
resetForm();
|
||||
router.push('/admin/desa/potensi/kategori-potensi');
|
||||
} catch (error) {
|
||||
console.error('Error creating kategori potensi:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 'sm', md: 'lg' }} py="md">
|
||||
{/* Header dengan back button */}
|
||||
<Group mb="md">
|
||||
<Tooltip label="Kembali ke halaman sebelumnya" withArrow>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<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 Kategori Potensi
|
||||
</Title>
|
||||
@@ -63,12 +70,23 @@ function CreateKategoriPotensi() {
|
||||
<TextInput
|
||||
label="Nama Kategori Potensi"
|
||||
placeholder="Masukkan nama kategori potensi"
|
||||
defaultValue={createState.create.form.nama || ''}
|
||||
value={createState.create.form.nama || ''}
|
||||
onChange={(e) => (createState.create.form.nama = 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"
|
||||
@@ -79,7 +97,7 @@ function CreateKategoriPotensi() {
|
||||
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
|
||||
}}
|
||||
>
|
||||
Simpan
|
||||
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import { Box, Button, Center, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title, Tooltip, Pagination, Group } from '@mantine/core';
|
||||
import { IconEdit, IconSearch, IconTrash, IconPlus } from '@tabler/icons-react';
|
||||
import { Box, Button, Center, Group, Pagination, Paper, Skeleton, Stack, Table, TableTbody, TableTd, TableTh, TableThead, TableTr, Text, Title } from '@mantine/core';
|
||||
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import HeaderSearch from '../../../_com/header';
|
||||
import potensiDesaState from '../../../_state/desa/potensi';
|
||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||
import potensiDesaState from '../../../_state/desa/potensi';
|
||||
|
||||
function KategoriPotensi() {
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -62,7 +62,6 @@ function ListKategoriPotensi({ search }: { search: string }) {
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={4}>List Kategori Potensi</Title>
|
||||
<Tooltip label="Tambah Kategori Potensi" withArrow>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
color="blue"
|
||||
@@ -71,7 +70,6 @@ function ListKategoriPotensi({ search }: { search: string }) {
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user