Merge pull request 'nico/4-mar-26/realiasasi-apbdes' (#73) from nico/4-mar-26/realiasasi-apbdes into staggingweb
Reviewed-on: #73
This commit is contained in:
@@ -12,6 +12,8 @@ const templateForm = z.object({
|
||||
content: z.string().min(3, "Content minimal 3 karakter"),
|
||||
kategoriBeritaId: z.string().nonempty(),
|
||||
imageId: z.string().nonempty(),
|
||||
imageIds: z.array(z.string()),
|
||||
linkVideo: z.string().optional(),
|
||||
});
|
||||
|
||||
// 2. Default value form berita (hindari uncontrolled input)
|
||||
@@ -21,6 +23,8 @@ const defaultForm = {
|
||||
imageId: "",
|
||||
content: "",
|
||||
kategoriBeritaId: "",
|
||||
imageIds: [] as string[],
|
||||
linkVideo: "",
|
||||
};
|
||||
|
||||
// 4. Berita proxy
|
||||
@@ -62,14 +66,7 @@ const berita = proxy({
|
||||
// State untuk berita utama (hanya 1)
|
||||
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.BeritaGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
kategoriBerita: true;
|
||||
};
|
||||
}>[]
|
||||
| null,
|
||||
data: null as any[] | null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
@@ -79,14 +76,14 @@ const berita = proxy({
|
||||
berita.findMany.loading = true;
|
||||
berita.findMany.page = page;
|
||||
berita.findMany.search = search;
|
||||
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
if (kategori) query.kategori = kategori;
|
||||
|
||||
|
||||
const res = await ApiFetch.api.desa.berita["find-many"].get({ query });
|
||||
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
berita.findMany.data = res.data.data ?? [];
|
||||
berita.findMany.totalPages = res.data.totalPages ?? 1;
|
||||
@@ -103,18 +100,19 @@ const berita = proxy({
|
||||
const elapsed = Date.now() - startTime;
|
||||
const minDelay = 300;
|
||||
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
berita.findMany.loading = false;
|
||||
}, delay);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
findUnique: {
|
||||
data: null as Prisma.BeritaGetPayload<{
|
||||
include: {
|
||||
image: true;
|
||||
images: true;
|
||||
kategoriBerita: true;
|
||||
};
|
||||
}> | null,
|
||||
@@ -199,6 +197,8 @@ const berita = proxy({
|
||||
content: data.content,
|
||||
kategoriBeritaId: data.kategoriBeritaId || "",
|
||||
imageId: data.imageId || "",
|
||||
imageIds: data.images?.map((img: any) => img.id) || [],
|
||||
linkVideo: data.linkVideo || "",
|
||||
};
|
||||
return data; // Return the loaded data
|
||||
} else {
|
||||
@@ -237,6 +237,8 @@ const berita = proxy({
|
||||
content: this.form.content,
|
||||
kategoriBeritaId: this.form.kategoriBeritaId || null,
|
||||
imageId: this.form.imageId,
|
||||
imageIds: this.form.imageIds,
|
||||
linkVideo: this.form.linkVideo,
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
297
src/app/admin/(dashboard)/_state/desa/musik.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ApiFetch from "@/lib/api-fetch";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
// 1. Schema validasi dengan Zod
|
||||
const templateForm = z.object({
|
||||
judul: z.string().min(3, "Judul minimal 3 karakter"),
|
||||
artis: z.string().min(3, "Artis minimal 3 karakter"),
|
||||
deskripsi: z.string().optional(),
|
||||
durasi: z.string().min(3, "Durasi minimal 3 karakter"),
|
||||
audioFileId: z.string().nonempty(),
|
||||
coverImageId: z.string().nonempty(),
|
||||
genre: z.string().optional(),
|
||||
tahunRilis: z.number().optional().or(z.literal(undefined)),
|
||||
});
|
||||
|
||||
// 2. Default value form musik
|
||||
const defaultForm = {
|
||||
judul: "",
|
||||
artis: "",
|
||||
deskripsi: "",
|
||||
durasi: "",
|
||||
audioFileId: "",
|
||||
coverImageId: "",
|
||||
genre: "",
|
||||
tahunRilis: undefined as number | undefined,
|
||||
};
|
||||
|
||||
// 3. Musik proxy
|
||||
const musik = proxy({
|
||||
create: {
|
||||
form: { ...defaultForm },
|
||||
loading: false,
|
||||
async create() {
|
||||
const cek = templateForm.safeParse(musik.create.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
return toast.error(err);
|
||||
}
|
||||
|
||||
try {
|
||||
musik.create.loading = true;
|
||||
const res = await ApiFetch.api.desa.musik["create"].post(
|
||||
musik.create.form
|
||||
);
|
||||
if (res.status === 200) {
|
||||
musik.findMany.load();
|
||||
return toast.success("Musik berhasil disimpan!");
|
||||
}
|
||||
|
||||
return toast.error("Gagal menyimpan musik");
|
||||
} catch (error) {
|
||||
console.log((error as Error).message);
|
||||
} finally {
|
||||
musik.create.loading = false;
|
||||
}
|
||||
},
|
||||
resetForm() {
|
||||
musik.create.form = { ...defaultForm };
|
||||
},
|
||||
},
|
||||
|
||||
findMany: {
|
||||
data: null as
|
||||
| Prisma.MusikDesaGetPayload<{
|
||||
include: {
|
||||
audioFile: true;
|
||||
coverImage: true;
|
||||
};
|
||||
}>[]
|
||||
| null,
|
||||
page: 1,
|
||||
totalPages: 1,
|
||||
loading: false,
|
||||
search: "",
|
||||
load: async (page = 1, limit = 10, search = "", genre = "") => {
|
||||
const startTime = Date.now();
|
||||
musik.findMany.loading = true;
|
||||
musik.findMany.page = page;
|
||||
musik.findMany.search = search;
|
||||
|
||||
try {
|
||||
const query: any = { page, limit };
|
||||
if (search) query.search = search;
|
||||
if (genre) query.genre = genre;
|
||||
|
||||
const res = await ApiFetch.api.desa.musik["find-many"].get({ query });
|
||||
|
||||
if (res.status === 200 && res.data?.success) {
|
||||
musik.findMany.data = res.data.data ?? [];
|
||||
musik.findMany.totalPages = res.data.totalPages ?? 1;
|
||||
} else {
|
||||
musik.findMany.data = [];
|
||||
musik.findMany.totalPages = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Gagal fetch musik paginated:", err);
|
||||
musik.findMany.data = [];
|
||||
musik.findMany.totalPages = 1;
|
||||
} finally {
|
||||
const elapsed = Date.now() - startTime;
|
||||
const minDelay = 300;
|
||||
const delay = elapsed < minDelay ? minDelay - elapsed : 0;
|
||||
|
||||
setTimeout(() => {
|
||||
musik.findMany.loading = false;
|
||||
}, delay);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
findUnique: {
|
||||
data: null as Prisma.MusikDesaGetPayload<{
|
||||
include: {
|
||||
audioFile: true;
|
||||
coverImage: true;
|
||||
};
|
||||
}> | null,
|
||||
loading: false,
|
||||
async load(id: string) {
|
||||
try {
|
||||
musik.findUnique.loading = true;
|
||||
const res = await fetch(`/api/desa/musik/${id}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
musik.findUnique.data = data.data ?? null;
|
||||
} else {
|
||||
console.error("Failed to fetch musik:", res.statusText);
|
||||
musik.findUnique.data = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching musik:", error);
|
||||
musik.findUnique.data = null;
|
||||
} finally {
|
||||
musik.findUnique.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
loading: false,
|
||||
async byId(id: string) {
|
||||
if (!id) return toast.warn("ID tidak valid");
|
||||
|
||||
try {
|
||||
musik.delete.loading = true;
|
||||
|
||||
const response = await fetch(`/api/desa/musik/delete/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result?.success) {
|
||||
toast.success(result.message || "Musik berhasil dihapus");
|
||||
await musik.findMany.load();
|
||||
} else {
|
||||
toast.error(result?.message || "Gagal menghapus musik");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Gagal delete:", error);
|
||||
toast.error("Terjadi kesalahan saat menghapus musik");
|
||||
} finally {
|
||||
musik.delete.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
edit: {
|
||||
id: "",
|
||||
form: { ...defaultForm },
|
||||
loading: false,
|
||||
|
||||
async load(id: string) {
|
||||
if (!id) {
|
||||
toast.warn("ID tidak valid");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/desa/musik/${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 = {
|
||||
judul: data.judul,
|
||||
artis: data.artis,
|
||||
deskripsi: data.deskripsi || "",
|
||||
durasi: data.durasi,
|
||||
audioFileId: data.audioFileId || "",
|
||||
coverImageId: data.coverImageId || "",
|
||||
genre: data.genre || "",
|
||||
tahunRilis: data.tahunRilis || undefined,
|
||||
};
|
||||
return data;
|
||||
} else {
|
||||
throw new Error(result?.message || "Gagal memuat data");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading musik:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Gagal memuat data"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async update() {
|
||||
const cek = templateForm.safeParse(musik.edit.form);
|
||||
if (!cek.success) {
|
||||
const err = `[${cek.error.issues
|
||||
.map((v) => `${v.path.join(".")}`)
|
||||
.join("\n")}] required`;
|
||||
toast.error(err);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
musik.edit.loading = true;
|
||||
|
||||
const response = await fetch(`/api/desa/musik/${this.id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
judul: this.form.judul,
|
||||
artis: this.form.artis,
|
||||
deskripsi: this.form.deskripsi,
|
||||
durasi: this.form.durasi,
|
||||
audioFileId: this.form.audioFileId,
|
||||
coverImageId: this.form.coverImageId,
|
||||
genre: this.form.genre,
|
||||
tahunRilis: this.form.tahunRilis,
|
||||
}),
|
||||
});
|
||||
|
||||
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("Musik berhasil diupdate");
|
||||
await musik.findMany.load();
|
||||
return true;
|
||||
} else {
|
||||
throw new Error(result.message || "Gagal update musik");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error updating musik:", error);
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Terjadi kesalahan saat update musik"
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
musik.edit.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
musik.edit.id = "";
|
||||
musik.edit.form = { ...defaultForm };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 4. State global
|
||||
const stateDashboardMusik = proxy({
|
||||
musik,
|
||||
});
|
||||
|
||||
export default stateDashboardMusik;
|
||||
@@ -5,20 +5,20 @@ import { toast } from "react-toastify";
|
||||
import { proxy } from "valtio";
|
||||
import { z } from "zod";
|
||||
|
||||
// --- Zod Schema ---
|
||||
// --- Zod Schema untuk APBDes Item (tanpa field kalkulasi) ---
|
||||
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(),
|
||||
anggaran: z.number().min(0, "Anggaran tidak boleh negatif"),
|
||||
level: z.number().int().min(1).max(3),
|
||||
tipe: z.enum(['pendapatan', 'belanja', 'pembiayaan']).nullable().optional(),
|
||||
});
|
||||
|
||||
const ApbdesFormSchema = z.object({
|
||||
tahun: z.number().int().min(2000, "Tahun tidak valid"),
|
||||
name: z.string().optional(),
|
||||
deskripsi: z.string().optional(),
|
||||
jumlah: z.string().optional(),
|
||||
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"),
|
||||
@@ -27,31 +27,22 @@ const ApbdesFormSchema = z.object({
|
||||
// --- Default Form ---
|
||||
const defaultApbdesForm = {
|
||||
tahun: new Date().getFullYear(),
|
||||
name: "",
|
||||
deskripsi: "",
|
||||
jumlah: "",
|
||||
imageId: "",
|
||||
fileId: "",
|
||||
items: [] as z.infer<typeof ApbdesItemSchema>[],
|
||||
};
|
||||
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
// --- Helper: hitung selisih & persentase otomatis (opsional di frontend) ---
|
||||
// --- Helper: Normalize item (tanpa kalkulasi, backend yang hitung) ---
|
||||
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 = realisasi - anggaran; // 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,
|
||||
anggaran: item.anggaran ?? 0,
|
||||
level: item.level || 1,
|
||||
tipe: item.tipe, // biarkan null jika memang null
|
||||
tipe: item.tipe ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -158,33 +149,37 @@ const apbdes = proxy({
|
||||
findUnique: {
|
||||
data: null as
|
||||
| Prisma.APBDesGetPayload<{
|
||||
include: { image: true; file: true; items: true };
|
||||
include: { image: true; file: true; items: { include: { realisasiItems: 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;
|
||||
}
|
||||
|
||||
|
||||
// Prevent multiple simultaneous loads
|
||||
if (this.loading) {
|
||||
console.log("⚠️ Already loading, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
|
||||
try {
|
||||
// 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 {
|
||||
@@ -244,15 +239,15 @@ const apbdes = proxy({
|
||||
this.id = data.id;
|
||||
this.form = {
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
name: data.name || "",
|
||||
deskripsi: data.deskripsi || "",
|
||||
jumlah: data.jumlah || "",
|
||||
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',
|
||||
})),
|
||||
@@ -317,6 +312,82 @@ const apbdes = proxy({
|
||||
this.form = { ...defaultApbdesForm };
|
||||
},
|
||||
},
|
||||
|
||||
// =========================================
|
||||
// REALISASI STATE MANAGEMENT
|
||||
// =========================================
|
||||
realisasi: {
|
||||
// Create realisasi
|
||||
async create(itemId: string, data: { kode: string; jumlah: number; tanggal: string; keterangan?: string; buktiFileId?: string }) {
|
||||
try {
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any)[itemId].realisasi.post(data);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("Realisasi berhasil ditambahkan");
|
||||
// Reload findUnique untuk update data
|
||||
const currentId = apbdes.findUnique.data?.id;
|
||||
if (currentId) {
|
||||
await apbdes.findUnique.load(currentId);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal menambahkan realisasi");
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Create realisasi error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat menambahkan realisasi");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Update realisasi
|
||||
async update(realisasiId: string, data: { kode?: string; jumlah?: number; tanggal?: string; keterangan?: string; buktiFileId?: string }) {
|
||||
try {
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].put(data);
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("Realisasi berhasil diperbarui");
|
||||
// Reload findUnique untuk update data
|
||||
const currentId = apbdes.findUnique.data?.id;
|
||||
if (currentId) {
|
||||
await apbdes.findUnique.load(currentId);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal memperbarui realisasi");
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Update realisasi error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat memperbarui realisasi");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete realisasi
|
||||
async delete(realisasiId: string) {
|
||||
try {
|
||||
const res = await (ApiFetch.api.landingpage.apbdes as any).realisasi[realisasiId].delete();
|
||||
|
||||
if (res.data?.success) {
|
||||
toast.success("Realisasi berhasil dihapus");
|
||||
// Reload findUnique untuk update data
|
||||
if (apbdes.findUnique.data) {
|
||||
await apbdes.findUnique.load(apbdes.findUnique.data.id);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
toast.error(res.data?.message || "Gagal menghapus realisasi");
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Delete realisasi error:", error);
|
||||
toast.error(error?.message || "Terjadi kesalahan saat menghapus realisasi");
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default apbdes;
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Loader
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import { Dropzone } from "@mantine/dropzone";
|
||||
import {
|
||||
@@ -25,19 +27,51 @@ import {
|
||||
IconPhoto,
|
||||
IconUpload,
|
||||
IconX,
|
||||
IconVideo,
|
||||
IconTrash,
|
||||
} 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";
|
||||
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
|
||||
|
||||
interface ExistingImage {
|
||||
id: string;
|
||||
link: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BeritaData {
|
||||
id: string;
|
||||
judul: string;
|
||||
deskripsi: string;
|
||||
content: string;
|
||||
kategoriBeritaId: string | null;
|
||||
imageId: string | null;
|
||||
image?: { link: string } | null;
|
||||
images?: ExistingImage[];
|
||||
linkVideo?: string | null;
|
||||
}
|
||||
|
||||
function EditBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
|
||||
// Featured image state
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
|
||||
// Gallery images state
|
||||
const [existingGalleryImages, setExistingGalleryImages] = useState<ExistingImage[]>([]);
|
||||
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
|
||||
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
|
||||
|
||||
// YouTube link state
|
||||
const [youtubeLink, setYoutubeLink] = useState('');
|
||||
const [originalYoutubeLink, setOriginalYoutubeLink] = useState('');
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
@@ -48,9 +82,17 @@ function EditBerita() {
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
kategoriBeritaId: "",
|
||||
content: "",
|
||||
imageId: "",
|
||||
imageUrl: ""
|
||||
});
|
||||
|
||||
// Helper function to check if HTML content is empty
|
||||
const isHtmlEmpty = (html: string) => {
|
||||
// Remove all HTML tags and check if there's any text content
|
||||
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||
return textContent === '';
|
||||
};
|
||||
@@ -61,21 +103,12 @@ function EditBerita() {
|
||||
formData.judul?.trim() !== '' &&
|
||||
formData.kategoriBeritaId !== '' &&
|
||||
!isHtmlEmpty(formData.deskripsi) &&
|
||||
(file !== null || originalData.imageId !== '') && // Either a new file is selected or an existing image exists
|
||||
(file !== null || originalData.imageId !== '') &&
|
||||
!isHtmlEmpty(formData.content)
|
||||
);
|
||||
};
|
||||
|
||||
const [originalData, setOriginalData] = useState({
|
||||
judul: "",
|
||||
deskripsi: "",
|
||||
kategoriBeritaId: "",
|
||||
content: "",
|
||||
imageId: "",
|
||||
imageUrl: ""
|
||||
});
|
||||
|
||||
// Load kategori + berita
|
||||
// Load data
|
||||
useEffect(() => {
|
||||
beritaState.kategoriBerita.findMany.load();
|
||||
|
||||
@@ -84,7 +117,7 @@ function EditBerita() {
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
const data = await stateDashboardBerita.berita.edit.load(id);
|
||||
const data = await stateDashboardBerita.berita.edit.load(id) as BeritaData | null;
|
||||
if (data) {
|
||||
setFormData({
|
||||
judul: data.judul || "",
|
||||
@@ -106,6 +139,17 @@ function EditBerita() {
|
||||
if (data?.image?.link) {
|
||||
setPreviewImage(data.image.link);
|
||||
}
|
||||
|
||||
// Load gallery images
|
||||
if (data?.images && data.images.length > 0) {
|
||||
setExistingGalleryImages(data.images);
|
||||
}
|
||||
|
||||
// Load YouTube link
|
||||
if (data?.linkVideo) {
|
||||
setYoutubeLink(data.linkVideo);
|
||||
setOriginalYoutubeLink(data.linkVideo);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading berita:", error);
|
||||
@@ -120,27 +164,59 @@ function EditBerita() {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleGalleryDrop = (files: File[]) => {
|
||||
const maxImages = 10;
|
||||
const currentCount = existingGalleryImages.length + galleryFiles.length;
|
||||
const availableSlots = maxImages - currentCount;
|
||||
|
||||
if (availableSlots <= 0) {
|
||||
toast.warn('Maksimal 10 gambar untuk galeri');
|
||||
return;
|
||||
}
|
||||
|
||||
const newFiles = files.slice(0, availableSlots);
|
||||
|
||||
if (newFiles.length === 0) {
|
||||
toast.warn('Tidak ada slot tersisa untuk gambar galeri');
|
||||
return;
|
||||
}
|
||||
|
||||
setGalleryFiles([...galleryFiles, ...newFiles]);
|
||||
|
||||
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
|
||||
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
|
||||
};
|
||||
|
||||
const removeGalleryImage = (index: number, isExisting: boolean = false) => {
|
||||
if (isExisting) {
|
||||
setExistingGalleryImages(existingGalleryImages.filter((_, i) => i !== index));
|
||||
} else {
|
||||
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
|
||||
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.judul?.trim()) {
|
||||
toast.error('Judul wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!formData.kategoriBeritaId) {
|
||||
toast.error('Kategori wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isHtmlEmpty(formData.deskripsi)) {
|
||||
toast.error('Deskripsi singkat wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!file && !originalData.imageId) {
|
||||
toast.error('Gambar wajib dipilih');
|
||||
toast.error('Gambar utama wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isHtmlEmpty(formData.content)) {
|
||||
toast.error('Konten wajib diisi');
|
||||
return;
|
||||
@@ -148,12 +224,14 @@ function EditBerita() {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Update global state hanya sekali di sini
|
||||
|
||||
// Update global state
|
||||
beritaState.berita.edit.form = {
|
||||
...beritaState.berita.edit.form,
|
||||
...formData,
|
||||
};
|
||||
|
||||
// Upload new featured image if changed
|
||||
if (file) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file,
|
||||
@@ -162,12 +240,33 @@ function EditBerita() {
|
||||
const uploaded = res.data?.data;
|
||||
|
||||
if (!uploaded?.id) {
|
||||
return toast.error("Gagal upload gambar");
|
||||
return toast.error("Gagal upload gambar utama");
|
||||
}
|
||||
|
||||
beritaState.berita.edit.form.imageId = uploaded.id;
|
||||
}
|
||||
|
||||
// Upload new gallery images
|
||||
const newGalleryIds: string[] = [];
|
||||
for (const galleryFile of galleryFiles) {
|
||||
const galleryRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: galleryFile,
|
||||
name: galleryFile.name,
|
||||
});
|
||||
const galleryUploaded = galleryRes.data?.data;
|
||||
if (galleryUploaded?.id) {
|
||||
newGalleryIds.push(galleryUploaded.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Combine existing (not removed) and new gallery images
|
||||
const remainingExistingIds = existingGalleryImages.map(img => img.id);
|
||||
beritaState.berita.edit.form.imageIds = [...remainingExistingIds, ...newGalleryIds];
|
||||
|
||||
// Set YouTube link
|
||||
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||
beritaState.berita.edit.form.linkVideo = embedLink || '';
|
||||
|
||||
await beritaState.berita.edit.update();
|
||||
toast.success("Berita berhasil diperbarui!");
|
||||
router.push("/admin/desa/berita/list-berita");
|
||||
@@ -189,9 +288,12 @@ function EditBerita() {
|
||||
});
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setFile(null);
|
||||
setYoutubeLink(originalYoutubeLink);
|
||||
toast.info("Form dikembalikan ke data awal");
|
||||
};
|
||||
|
||||
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header */}
|
||||
@@ -219,6 +321,7 @@ function EditBerita() {
|
||||
style={{ border: "1px solid #e0e0e0" }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Judul */}
|
||||
<TextInput
|
||||
label="Judul"
|
||||
placeholder="Masukkan judul"
|
||||
@@ -227,6 +330,7 @@ function EditBerita() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Kategori */}
|
||||
<Select
|
||||
value={formData.kategoriBeritaId}
|
||||
onChange={(val) => handleChange("kategoriBeritaId", val || "")}
|
||||
@@ -241,9 +345,9 @@ function EditBerita() {
|
||||
clearable
|
||||
searchable
|
||||
required
|
||||
error={!formData.kategoriBeritaId ? "Pilih kategori" : undefined}
|
||||
/>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold">
|
||||
Deskripsi Singkat
|
||||
@@ -256,11 +360,10 @@ function EditBerita() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
{/* Upload Gambar */}
|
||||
{/* Featured Image */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Berita
|
||||
Gambar Utama (Featured)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
@@ -274,17 +377,13 @@ function EditBerita() {
|
||||
toast.error("File tidak valid, gunakan format gambar")
|
||||
}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ "image/*": [] }}
|
||||
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}
|
||||
/>
|
||||
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={48} color="red" stroke={1.5} />
|
||||
@@ -292,14 +391,6 @@ function EditBerita() {
|
||||
<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>
|
||||
|
||||
@@ -328,9 +419,7 @@ function EditBerita() {
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
@@ -338,6 +427,138 @@ function EditBerita() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Gallery Images */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Galeri Gambar (Opsional - Maksimal 10)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleGalleryDrop}
|
||||
onReject={() => toast.error("File tidak valid, gunakan format gambar")}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ "image/*": ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="md"
|
||||
multiple
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={120}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={40} color={colors["blue-button"]} stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={40} color="red" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={40} color="#868e96" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
<Text ta="center" mt="sm" size="xs" color="dimmed">
|
||||
Seret gambar untuk menambahkan ke galeri
|
||||
</Text>
|
||||
</Dropzone>
|
||||
|
||||
{/* Existing Gallery Images */}
|
||||
{existingGalleryImages.length > 0 && (
|
||||
<Box mt="sm">
|
||||
<Text fz="xs" fw="bold" mb={6} c="dimmed">
|
||||
Gambar Existing ({existingGalleryImages.length})
|
||||
</Text>
|
||||
<Grid gutter="sm">
|
||||
{existingGalleryImages.map((img, index) => (
|
||||
<Grid.Col span={4} key={img.id}>
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Image src={img.link} alt={img.name} radius="sm" height={100} fit="cover" />
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => removeGalleryImage(index, true)}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* New Gallery Images */}
|
||||
{galleryPreviews.length > 0 && (
|
||||
<Box mt="sm">
|
||||
<Text fz="xs" fw="bold" mb={6} c="dimmed">
|
||||
Gambar Baru ({galleryPreviews.length})
|
||||
</Text>
|
||||
<Grid gutter="sm">
|
||||
{galleryPreviews.map((preview, index) => (
|
||||
<Grid.Col span={4} key={index}>
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Image src={preview} alt={`New ${index}`} radius="sm" height={100} fit="cover" />
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => removeGalleryImage(index, false)}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* YouTube Video */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Link Video YouTube (Opsional)
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
value={youtubeLink}
|
||||
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
|
||||
leftSection={<IconVideo size={18} />}
|
||||
rightSection={
|
||||
youtubeLink && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setYoutubeLink('')}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{embedLink && (
|
||||
<Box mt="sm" pos="relative">
|
||||
<iframe
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
width: '100%',
|
||||
height: 250,
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
src={embedLink}
|
||||
title="Preview Video"
|
||||
allowFullScreen
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Konten */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold">
|
||||
@@ -351,9 +572,8 @@ function EditBerita() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Action */}
|
||||
{/* Action Buttons */}
|
||||
<Group justify="right">
|
||||
{/* Tombol Batal */}
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
@@ -363,8 +583,6 @@ function EditBerita() {
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import { Box, Button, Group, Image, Paper, Skeleton, Stack, Text } from '@mantine/core';
|
||||
import { Box, Button, Card, Grid, Group, Image, Paper, Skeleton, Stack, Text, Badge, AspectRatio } from '@mantine/core';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import { IconArrowBack, IconEdit, IconTrash, IconVideo } from '@tabler/icons-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
@@ -10,6 +10,23 @@ import { ModalKonfirmasiHapus } from '@/app/admin/(dashboard)/_com/modalKonfirma
|
||||
import stateDashboardBerita from '@/app/admin/(dashboard)/_state/desa/berita';
|
||||
import colors from '@/con/colors';
|
||||
|
||||
interface ExistingImage {
|
||||
id: string;
|
||||
link: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface BeritaDetail {
|
||||
id: string;
|
||||
judul: string;
|
||||
deskripsi: string;
|
||||
content: string;
|
||||
image?: { link: string } | null;
|
||||
images?: ExistingImage[];
|
||||
linkVideo?: string | null;
|
||||
kategoriBerita?: { name: string } | null;
|
||||
}
|
||||
|
||||
function DetailBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
const [modalHapus, setModalHapus] = useState(false);
|
||||
@@ -38,7 +55,7 @@ function DetailBerita() {
|
||||
);
|
||||
}
|
||||
|
||||
const data = beritaState.berita.findUnique.data;
|
||||
const data = beritaState.berita.findUnique.data as unknown as BeritaDetail;
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'xs' }} py="xs">
|
||||
@@ -68,71 +85,131 @@ function DetailBerita() {
|
||||
|
||||
<Paper bg="#ECEEF8" p="md" radius="md" shadow="xs">
|
||||
<Stack gap="sm">
|
||||
{/* Kategori */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Kategori</Text>
|
||||
<Text fz="md" c="dimmed">{data.kategoriBerita?.name || '-'}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Judul */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Judul</Text>
|
||||
<Text fz="md" c="dimmed">{data.judul || '-'}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||
<Text fz="md" c="dimmed" style={{ wordBreak: "break-word", whiteSpace: "normal" }} dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }} />
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
style={{ wordBreak: "break-word", whiteSpace: "normal" }}
|
||||
dangerouslySetInnerHTML={{ __html: data.deskripsi || '-' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Gambar Utama (Featured) */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Gambar</Text>
|
||||
<Text fz="lg" fw="bold">Gambar Utama</Text>
|
||||
{data.image?.link ? (
|
||||
<Image
|
||||
src={data.image.link}
|
||||
alt={data.judul || 'Gambar Berita'}
|
||||
w={200}
|
||||
h={200}
|
||||
w={{ base: '100%', md: 400 }}
|
||||
h={300}
|
||||
radius="md"
|
||||
fit="cover"
|
||||
loading='lazy'
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<Text fz="sm" c="dimmed">Tidak ada gambar</Text>
|
||||
<Text fz="sm" c="dimmed">Tidak ada gambar utama</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Gallery Images */}
|
||||
{data.images && data.images.length > 0 && (
|
||||
<Box>
|
||||
<Group gap="xs" mb="sm">
|
||||
<Text fz="lg" fw="bold">Galeri Gambar</Text>
|
||||
<Badge color="blue" variant="light">
|
||||
{data.images.length}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Grid gutter="md">
|
||||
{data.images.map((img, index) => (
|
||||
<Grid.Col span={{ base: 6, md: 4 }} key={img.id}>
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Image
|
||||
src={img.link}
|
||||
alt={img.name || `Gallery ${index + 1}`}
|
||||
h={150}
|
||||
radius="sm"
|
||||
fit="cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* YouTube Video */}
|
||||
{data.linkVideo && (
|
||||
<Box>
|
||||
<Group gap="xs" mb="sm">
|
||||
<Text fz="lg" fw="bold">Video YouTube</Text>
|
||||
<IconVideo size={20} color={colors['blue-button']} />
|
||||
</Group>
|
||||
<AspectRatio ratio={16 / 9} mah={400}>
|
||||
<iframe
|
||||
src={data.linkVideo}
|
||||
title="YouTube Video"
|
||||
allowFullScreen
|
||||
style={{ borderRadius: 10, border: '1px solid #ddd' }}
|
||||
/>
|
||||
</AspectRatio>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Konten */}
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Konten</Text>
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
||||
/>
|
||||
<Paper bg="white" p="md" radius="md" mt="xs">
|
||||
<Text
|
||||
fz="md"
|
||||
c="dimmed"
|
||||
dangerouslySetInnerHTML={{ __html: data.content || '-' }}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
{/* Action Button */}
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setSelectedId(data.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconTrash size={20} />
|
||||
</Button>
|
||||
{/* Action Buttons */}
|
||||
<Group gap="sm" mt="md">
|
||||
<Button
|
||||
color="red"
|
||||
onClick={() => {
|
||||
setSelectedId(data.id);
|
||||
setModalHapus(true);
|
||||
}}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<IconTrash size={20} />}
|
||||
>
|
||||
Hapus
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
>
|
||||
<IconEdit size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
onClick={() => router.push(`/admin/desa/berita/list-berita/${data.id}/edit`)}
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<IconEdit size={20} />}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
@@ -15,26 +15,38 @@ import {
|
||||
TextInput,
|
||||
Title,
|
||||
Loader,
|
||||
ActionIcon
|
||||
ActionIcon,
|
||||
Grid,
|
||||
Card,
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconVideo, IconTrash } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import { convertYoutubeUrlToEmbed } from '@/app/admin/(dashboard)/desa/gallery/lib/youtube-utils';
|
||||
|
||||
export default function CreateBerita() {
|
||||
const beritaState = useProxy(stateDashboardBerita);
|
||||
const router = useRouter();
|
||||
|
||||
// Featured image state
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Gallery images state
|
||||
const [galleryFiles, setGalleryFiles] = useState<File[]>([]);
|
||||
const [galleryPreviews, setGalleryPreviews] = useState<string[]>([]);
|
||||
|
||||
// YouTube link state
|
||||
const [youtubeLink, setYoutubeLink] = useState('');
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Helper function to check if HTML content is empty
|
||||
const isHtmlEmpty = (html: string) => {
|
||||
// Remove all HTML tags and check if there's any text content
|
||||
const textContent = html.replace(/<[^>]*>/g, '').trim();
|
||||
return textContent === '';
|
||||
};
|
||||
@@ -61,9 +73,35 @@ export default function CreateBerita() {
|
||||
kategoriBeritaId: '',
|
||||
imageId: '',
|
||||
content: '',
|
||||
imageIds: [],
|
||||
linkVideo: '',
|
||||
};
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
setGalleryFiles([]);
|
||||
setGalleryPreviews([]);
|
||||
setYoutubeLink('');
|
||||
};
|
||||
|
||||
const handleGalleryDrop = (files: File[]) => {
|
||||
const newFiles = files.filter(
|
||||
(_, index) => galleryFiles.length + index < 10 // Max 10 images
|
||||
);
|
||||
|
||||
if (newFiles.length === 0) {
|
||||
toast.warn('Maksimal 10 gambar untuk galeri');
|
||||
return;
|
||||
}
|
||||
|
||||
setGalleryFiles([...galleryFiles, ...newFiles]);
|
||||
|
||||
const newPreviews = newFiles.map((f) => URL.createObjectURL(f));
|
||||
setGalleryPreviews([...galleryPreviews, ...newPreviews]);
|
||||
};
|
||||
|
||||
const removeGalleryImage = (index: number) => {
|
||||
setGalleryFiles(galleryFiles.filter((_, i) => i !== index));
|
||||
setGalleryPreviews(galleryPreviews.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@@ -71,22 +109,22 @@ export default function CreateBerita() {
|
||||
toast.error('Judul wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!beritaState.berita.create.form.kategoriBeritaId) {
|
||||
toast.error('Kategori wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isHtmlEmpty(beritaState.berita.create.form.deskripsi)) {
|
||||
toast.error('Deskripsi singkat wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (!file) {
|
||||
toast.error('Gambar wajib dipilih');
|
||||
toast.error('Gambar utama wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (isHtmlEmpty(beritaState.berita.create.form.content)) {
|
||||
toast.error('Konten wajib diisi');
|
||||
return;
|
||||
@@ -94,21 +132,37 @@ export default function CreateBerita() {
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
if (!file) {
|
||||
return toast.warn('Silakan pilih file gambar terlebih dahulu');
|
||||
}
|
||||
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
// Upload featured image
|
||||
const featuredRes = 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');
|
||||
const featuredUploaded = featuredRes.data?.data;
|
||||
if (!featuredUploaded?.id) {
|
||||
return toast.error('Gagal mengunggah gambar utama');
|
||||
}
|
||||
beritaState.berita.create.form.imageId = featuredUploaded.id;
|
||||
|
||||
beritaState.berita.create.form.imageId = uploaded.id;
|
||||
// Upload gallery images
|
||||
const galleryIds: string[] = [];
|
||||
for (const galleryFile of galleryFiles) {
|
||||
const galleryRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: galleryFile,
|
||||
name: galleryFile.name,
|
||||
});
|
||||
const galleryUploaded = galleryRes.data?.data;
|
||||
if (galleryUploaded?.id) {
|
||||
galleryIds.push(galleryUploaded.id);
|
||||
}
|
||||
}
|
||||
beritaState.berita.create.form.imageIds = galleryIds;
|
||||
|
||||
// Set YouTube link if provided
|
||||
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||
if (embedLink) {
|
||||
beritaState.berita.create.form.linkVideo = embedLink;
|
||||
}
|
||||
|
||||
await beritaState.berita.create.create();
|
||||
|
||||
@@ -122,16 +176,13 @@ export default function CreateBerita() {
|
||||
}
|
||||
};
|
||||
|
||||
const embedLink = convertYoutubeUrlToEmbed(youtubeLink);
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header dengan tombol kembali */}
|
||||
{/* Header */}
|
||||
<Group mb="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.back()}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
@@ -148,6 +199,7 @@ export default function CreateBerita() {
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Judul */}
|
||||
<TextInput
|
||||
label="Judul"
|
||||
placeholder="Masukkan judul berita"
|
||||
@@ -156,6 +208,7 @@ export default function CreateBerita() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Kategori */}
|
||||
<Select
|
||||
label="Kategori"
|
||||
placeholder="Pilih kategori"
|
||||
@@ -182,6 +235,7 @@ export default function CreateBerita() {
|
||||
required
|
||||
/>
|
||||
|
||||
{/* Deskripsi */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>
|
||||
Deskripsi Singkat
|
||||
@@ -194,9 +248,10 @@ export default function CreateBerita() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Featured Image */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Gambar Berita
|
||||
Gambar Utama (Featured)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
@@ -232,17 +287,11 @@ export default function CreateBerita() {
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview Gambar"
|
||||
alt="Preview Gambar Utama"
|
||||
radius="md"
|
||||
style={{
|
||||
maxHeight: 200,
|
||||
objectFit: 'contain',
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
|
||||
loading="lazy"
|
||||
/>
|
||||
|
||||
{/* Tombol hapus (pojok kanan atas) */}
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
@@ -255,9 +304,7 @@ export default function CreateBerita() {
|
||||
setPreviewImage(null);
|
||||
setFile(null);
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
@@ -265,6 +312,102 @@ export default function CreateBerita() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Gallery Images */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Galeri Gambar (Opsional - Maksimal 10)
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={handleGalleryDrop}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
|
||||
maxSize={5 * 1024 ** 2}
|
||||
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
|
||||
radius="md"
|
||||
p="md"
|
||||
multiple
|
||||
>
|
||||
<Group justify="center" gap="xl" mih={120}>
|
||||
<Dropzone.Accept>
|
||||
<IconUpload size={40} color="var(--mantine-color-blue-6)" stroke={1.5} />
|
||||
</Dropzone.Accept>
|
||||
<Dropzone.Reject>
|
||||
<IconX size={40} color="var(--mantine-color-red-6)" stroke={1.5} />
|
||||
</Dropzone.Reject>
|
||||
<Dropzone.Idle>
|
||||
<IconPhoto size={40} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
<Text ta="center" mt="sm" size="xs" color="dimmed">
|
||||
Seret gambar atau klik untuk menambahkan ke galeri
|
||||
</Text>
|
||||
</Dropzone>
|
||||
|
||||
{galleryPreviews.length > 0 && (
|
||||
<Grid mt="sm" gutter="sm">
|
||||
{galleryPreviews.map((preview, index) => (
|
||||
<Grid.Col span={4} key={index}>
|
||||
<Card p="xs" radius="md" withBorder>
|
||||
<Image src={preview} alt={`Gallery ${index}`} radius="sm" height={100} fit="cover" />
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
pos="absolute"
|
||||
top={5}
|
||||
right={5}
|
||||
onClick={() => removeGalleryImage(index)}
|
||||
style={{ boxShadow: '0 2px 6px rgba(0,0,0,0.15)' }}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
))}
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* YouTube Video */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Link Video YouTube (Opsional)
|
||||
</Text>
|
||||
<TextInput
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
value={youtubeLink}
|
||||
onChange={(e) => setYoutubeLink(e.currentTarget.value)}
|
||||
leftSection={<IconVideo size={18} />}
|
||||
rightSection={
|
||||
youtubeLink && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => setYoutubeLink('')}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{embedLink && (
|
||||
<Box mt="sm" pos="relative">
|
||||
<iframe
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
width: '100%',
|
||||
height: 250,
|
||||
border: '1px solid #ddd',
|
||||
}}
|
||||
src={embedLink}
|
||||
title="Preview Video"
|
||||
allowFullScreen
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Konten */}
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>
|
||||
Konten
|
||||
@@ -277,6 +420,7 @@ export default function CreateBerita() {
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Buttons */}
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -287,8 +431,6 @@ export default function CreateBerita() {
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
{/* Tombol Simpan */}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
|
||||
@@ -0,0 +1,429 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client';
|
||||
|
||||
import { useProxy } from 'valtio/utils';
|
||||
import apbdes from '@/app/admin/(dashboard)/_state/landing-page/apbdes';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Title,
|
||||
Table,
|
||||
TableThead,
|
||||
TableTbody,
|
||||
TableTr,
|
||||
TableTh,
|
||||
TableTd,
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Modal,
|
||||
Divider,
|
||||
Center,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconCalendar,
|
||||
IconCoin,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
interface RealisasiManagerProps {
|
||||
itemId: string;
|
||||
itemKode: string;
|
||||
itemUraian: string;
|
||||
itemAnggaran: number;
|
||||
itemTotalRealisasi: number;
|
||||
itemPersentase: number;
|
||||
realisasiItems: any[];
|
||||
}
|
||||
|
||||
export default function RealisasiManager({
|
||||
itemId,
|
||||
itemKode,
|
||||
itemUraian,
|
||||
itemAnggaran,
|
||||
itemTotalRealisasi,
|
||||
itemPersentase,
|
||||
realisasiItems,
|
||||
}: RealisasiManagerProps) {
|
||||
const state = useProxy(apbdes);
|
||||
const [modalOpened, setModalOpened] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
kode: '',
|
||||
jumlah: 0,
|
||||
tanggal: new Date().toISOString().split('T')[0], // YYYY-MM-DD format for input
|
||||
keterangan: '',
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
kode: '',
|
||||
jumlah: 0,
|
||||
tanggal: new Date().toISOString().split('T')[0],
|
||||
keterangan: '',
|
||||
});
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
resetForm();
|
||||
setModalOpened(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (realisasi: any) => {
|
||||
const tanggal = new Date(realisasi.tanggal);
|
||||
const tanggalStr = tanggal.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
|
||||
setFormData({
|
||||
kode: realisasi.kode || '',
|
||||
jumlah: realisasi.jumlah,
|
||||
tanggal: tanggalStr,
|
||||
keterangan: realisasi.keterangan || '',
|
||||
});
|
||||
setEditingId(realisasi.id);
|
||||
setModalOpened(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (formData.jumlah <= 0) {
|
||||
return toast.warn('Jumlah realisasi harus lebih dari 0');
|
||||
}
|
||||
|
||||
if (!formData.kode || formData.kode.trim() === '') {
|
||||
return toast.warn('Kode realisasi wajib diisi');
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
if (editingId) {
|
||||
// Update existing realisasi
|
||||
const success = await state.realisasi.update(editingId, {
|
||||
kode: formData.kode,
|
||||
jumlah: formData.jumlah,
|
||||
tanggal: new Date(formData.tanggal).toISOString(),
|
||||
keterangan: formData.keterangan,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
toast.success('Realisasi berhasil diperbarui');
|
||||
}
|
||||
} else {
|
||||
// Create new realisasi
|
||||
const success = await state.realisasi.create(itemId, {
|
||||
kode: formData.kode,
|
||||
jumlah: formData.jumlah,
|
||||
tanggal: new Date(formData.tanggal).toISOString(),
|
||||
keterangan: formData.keterangan,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
toast.success('Realisasi berhasil ditambahkan');
|
||||
}
|
||||
}
|
||||
|
||||
setModalOpened(false);
|
||||
resetForm();
|
||||
} catch (error: any) {
|
||||
console.error('Error saving realisasi:', error);
|
||||
toast.error(error?.message || 'Gagal menyimpan realisasi');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (realisasiId: string) => {
|
||||
if (!confirm('Apakah Anda yakin ingin menghapus realisasi ini?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const success = await state.realisasi.delete(realisasiId);
|
||||
|
||||
if (success) {
|
||||
toast.success('Realisasi berhasil dihapus');
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting realisasi:', error);
|
||||
toast.error(error?.message || 'Gagal menghapus realisasi');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatRupiah = (amount: number) => {
|
||||
return new Intl.NumberFormat('id-ID', {
|
||||
style: 'currency',
|
||||
currency: 'IDR',
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getSisaAnggaran = () => {
|
||||
return itemAnggaran - itemTotalRealisasi;
|
||||
};
|
||||
|
||||
const getPersentaseColor = (persen: number) => {
|
||||
if (persen >= 100) return 'teal';
|
||||
if (persen >= 80) return 'blue';
|
||||
if (persen >= 60) return 'yellow';
|
||||
return 'red';
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md" mt="md">
|
||||
{/* Header */}
|
||||
<Group justify="space-between" mb="md">
|
||||
<Stack gap="xs">
|
||||
<Title order={6}>
|
||||
{itemKode} - {itemUraian}
|
||||
</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
Kelola realisasi untuk item ini
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button
|
||||
leftSection={<IconPlus size={18} />}
|
||||
onClick={handleOpenCreate}
|
||||
color="blue"
|
||||
variant="light"
|
||||
radius="md"
|
||||
>
|
||||
Tambah Realisasi
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<Group grow mb="md">
|
||||
<Paper withBorder p="md" radius="md" bg="blue.0">
|
||||
<Text fz="xs" c="blue.9" fw={600}>
|
||||
ANGGARAN
|
||||
</Text>
|
||||
<Text fz="lg" c="blue.9" fw={700}>
|
||||
{formatRupiah(itemAnggaran)}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md" bg="teal.0">
|
||||
<Text fz="xs" c="teal.9" fw={600}>
|
||||
TOTAL REALISASI
|
||||
</Text>
|
||||
<Text fz="lg" c="teal.9" fw={700}>
|
||||
{formatRupiah(itemTotalRealisasi)}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md" bg={getSisaAnggaran() >= 0 ? 'green.0' : 'red.0'}>
|
||||
<Text fz="xs" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={600}>
|
||||
SISA ANGGARAN
|
||||
</Text>
|
||||
<Text fz="lg" c={getSisaAnggaran() >= 0 ? 'green.9' : 'red.9'} fw={700}>
|
||||
{formatRupiah(getSisaAnggaran())}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<Paper withBorder p="md" radius="md" bg={getPersentaseColor(itemPersentase) + '.0'}>
|
||||
<Text fz="xs" c={getPersentaseColor(itemPersentase) + '.9'} fw={600}>
|
||||
PERSENTASE
|
||||
</Text>
|
||||
<Text fz="lg" c={getPersentaseColor(itemPersentase) + '.9'} fw={700}>
|
||||
{itemPersentase.toFixed(2)}%
|
||||
</Text>
|
||||
</Paper>
|
||||
</Group>
|
||||
|
||||
{/* Realisasi List */}
|
||||
{realisasiItems && realisasiItems.length > 0 ? (
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} mb="xs">
|
||||
Daftar Realisasi ({realisasiItems.length})
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover fz="sm">
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Kode</TableTh>
|
||||
<TableTh>Tanggal</TableTh>
|
||||
<TableTh>Uraian</TableTh>
|
||||
<TableTh ta="right">Jumlah</TableTh>
|
||||
<TableTh ta="center">Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{realisasiItems.map((realisasi) => (
|
||||
<TableTr key={realisasi.id}>
|
||||
<TableTd>
|
||||
<Badge variant="light" color="blue" size="sm">
|
||||
{realisasi.kode || '-'}
|
||||
</Badge>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Group gap="xs">
|
||||
<IconCalendar size={16} />
|
||||
<Text fz="sm">{formatDate(realisasi.tanggal)}</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fz="sm">{realisasi.keterangan || '-'}</Text>
|
||||
</TableTd>
|
||||
<TableTd ta="right">
|
||||
<Text fz="sm" fw={600} c="blue">
|
||||
{formatRupiah(realisasi.jumlah)}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd ta="center">
|
||||
<Group gap="xs" justify="center">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => handleOpenEdit(realisasi)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(realisasi.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text fz="sm" c="dimmed">
|
||||
Belum ada realisasi untuk item ini
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Klik tombol "Tambah Realisasi" untuk menambahkan
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{/* Modal Create/Edit */}
|
||||
<Modal
|
||||
opened={modalOpened}
|
||||
onClose={() => {
|
||||
setModalOpened(false);
|
||||
resetForm();
|
||||
}}
|
||||
title={
|
||||
<Text fz="lg" fw={600}>
|
||||
{editingId ? 'Edit Realisasi' : 'Tambah Realisasi Baru'}
|
||||
</Text>
|
||||
}
|
||||
size="md"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Info Item */}
|
||||
<Paper p="sm" bg="gray.0" radius="md">
|
||||
<Text fz="xs" c="dimmed">
|
||||
Item: {itemKode} - {itemUraian}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Anggaran: {formatRupiah(itemAnggaran)}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
Sudah terealisasi: {formatRupiah(itemTotalRealisasi)}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
<TextInput
|
||||
label="Kode Realisasi"
|
||||
placeholder="Contoh: 4.1.1-R1"
|
||||
value={formData.kode}
|
||||
onChange={(e) => setFormData({ ...formData, kode: e.target.value })}
|
||||
description="Kode unik untuk realisasi ini"
|
||||
required
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
label="Jumlah Realisasi (Rp)"
|
||||
value={formData.jumlah}
|
||||
onChange={(val) => setFormData({ ...formData, jumlah: Number(val) || 0 })}
|
||||
leftSection={<IconCoin size={16} />}
|
||||
thousandSeparator
|
||||
min={0}
|
||||
step={100000}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Tanggal Realisasi"
|
||||
type="date"
|
||||
value={formData.tanggal}
|
||||
onChange={(e) => setFormData({ ...formData, tanggal: e.target.value })}
|
||||
leftSection={<IconCalendar size={18} />}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Keterangan / Uraian"
|
||||
placeholder="Contoh: Penyaluran BLT Tahap 1"
|
||||
value={formData.keterangan}
|
||||
onChange={(e) => setFormData({ ...formData, keterangan: e.target.value })}
|
||||
description="Deskripsi singkat tentang realisasi ini"
|
||||
/>
|
||||
|
||||
<Divider my="xs" />
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
onClick={() => {
|
||||
setModalOpened(false);
|
||||
resetForm();
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
loading={loading}
|
||||
color="blue"
|
||||
leftSection={editingId ? <IconEdit size={16} /> : <IconPlus size={16} />}
|
||||
>
|
||||
{editingId ? 'Perbarui' : 'Tambah'} Realisasi
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -42,7 +42,6 @@ type ItemForm = {
|
||||
kode: string;
|
||||
uraian: string;
|
||||
anggaran: number;
|
||||
realisasi: number;
|
||||
level: number;
|
||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||
};
|
||||
@@ -71,7 +70,6 @@ function EditAPBDes() {
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
@@ -79,6 +77,9 @@ function EditAPBDes() {
|
||||
// Simpan data original untuk reset form
|
||||
const [originalData, setOriginalData] = useState({
|
||||
tahun: 0,
|
||||
name: '',
|
||||
deskripsi: '',
|
||||
jumlah: '',
|
||||
imageId: '',
|
||||
fileId: '',
|
||||
imageUrl: '',
|
||||
@@ -103,6 +104,9 @@ function EditAPBDes() {
|
||||
// Simpan data original untuk reset
|
||||
setOriginalData({
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
jumlah: data.jumlah || '',
|
||||
imageId: data.imageId || '',
|
||||
fileId: data.fileId || '',
|
||||
imageUrl: data.image?.link || '',
|
||||
@@ -112,6 +116,9 @@ function EditAPBDes() {
|
||||
// Set form dengan data lama (termasuk imageId dan fileId)
|
||||
apbdesState.edit.form = {
|
||||
tahun: data.tahun || new Date().getFullYear(),
|
||||
name: data.name || '',
|
||||
deskripsi: data.deskripsi || '',
|
||||
jumlah: data.jumlah || '',
|
||||
imageId: data.imageId || '',
|
||||
fileId: data.fileId || '',
|
||||
items: (data.items || []).map((item: any) => ({
|
||||
@@ -148,32 +155,25 @@ function EditAPBDes() {
|
||||
};
|
||||
|
||||
const handleAddItem = () => {
|
||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
||||
const { kode, uraian, anggaran, level, tipe } = newItem;
|
||||
if (!kode || !uraian) {
|
||||
return toast.warn('Kode dan uraian wajib diisi');
|
||||
}
|
||||
|
||||
const finalTipe = level === 1 ? null : tipe;
|
||||
const selisih = realisasi - anggaran;
|
||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||
|
||||
apbdesState.edit.addItem({
|
||||
kode,
|
||||
uraian,
|
||||
anggaran,
|
||||
realisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
level,
|
||||
tipe: finalTipe, // ✅ Tidak akan undefined
|
||||
tipe: finalTipe,
|
||||
});
|
||||
|
||||
|
||||
setNewItem({
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
@@ -238,9 +238,12 @@ function EditAPBDes() {
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
// Reset ke data original (tahun, imageId, fileId)
|
||||
// Reset ke data original (tahun, name, deskripsi, jumlah, imageId, fileId)
|
||||
apbdesState.edit.form = {
|
||||
tahun: originalData.tahun,
|
||||
name: originalData.name,
|
||||
deskripsi: originalData.deskripsi,
|
||||
jumlah: originalData.jumlah,
|
||||
imageId: originalData.imageId,
|
||||
fileId: originalData.fileId,
|
||||
items: [...apbdesState.edit.form.items], // keep existing items
|
||||
@@ -249,21 +252,20 @@ function EditAPBDes() {
|
||||
// Reset preview ke data original
|
||||
setPreviewImage(originalData.imageUrl || null);
|
||||
setPreviewDoc(originalData.fileUrl || null);
|
||||
|
||||
|
||||
// Reset file uploads
|
||||
setImageFile(null);
|
||||
setDocFile(null);
|
||||
|
||||
|
||||
// Reset new item form
|
||||
setNewItem({
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
|
||||
|
||||
toast.info('Form dikembalikan ke data awal');
|
||||
};
|
||||
|
||||
@@ -288,6 +290,33 @@ function EditAPBDes() {
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Header Form */}
|
||||
<TextInput
|
||||
label="Nama APBDes"
|
||||
placeholder="Contoh: APBDes Tahun 2025"
|
||||
value={apbdesState.edit.form.name}
|
||||
onChange={(e) =>
|
||||
(apbdesState.edit.form.name = e.target.value)
|
||||
}
|
||||
description="Opsional - akan diisi otomatis jika kosong"
|
||||
/>
|
||||
<TextInput
|
||||
label="Deskripsi"
|
||||
placeholder="Deskripsi APBDes (opsional)"
|
||||
value={apbdesState.edit.form.deskripsi}
|
||||
onChange={(e) =>
|
||||
(apbdesState.edit.form.deskripsi = e.target.value)
|
||||
}
|
||||
description="Opsional"
|
||||
/>
|
||||
<TextInput
|
||||
label="Jumlah Total"
|
||||
placeholder="Contoh: Rp 1.000.000.000"
|
||||
value={apbdesState.edit.form.jumlah}
|
||||
onChange={(e) =>
|
||||
(apbdesState.edit.form.jumlah = e.target.value)
|
||||
}
|
||||
description="Opsional - total keseluruhan anggaran"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Tahun"
|
||||
value={apbdesState.edit.form.tahun || new Date().getFullYear()}
|
||||
@@ -475,13 +504,6 @@ function EditAPBDes() {
|
||||
thousandSeparator
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Realisasi (Rp)"
|
||||
value={newItem.realisasi}
|
||||
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
|
||||
thousandSeparator
|
||||
min={0}
|
||||
/>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
@@ -505,7 +527,6 @@ function EditAPBDes() {
|
||||
<th>Kode</th>
|
||||
<th>Uraian</th>
|
||||
<th>Anggaran</th>
|
||||
<th>Realisasi</th>
|
||||
<th>Level</th>
|
||||
<th>Tipe</th>
|
||||
<th style={{ width: '50px' }}>Aksi</th>
|
||||
@@ -521,7 +542,6 @@ function EditAPBDes() {
|
||||
</td>
|
||||
<td>{item.uraian}</td>
|
||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||
<td>{item.realisasi.toLocaleString('id-ID')}</td>
|
||||
<td>
|
||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||
L{item.level}
|
||||
@@ -533,7 +553,7 @@ function EditAPBDes() {
|
||||
{item.tipe}
|
||||
</Badge>
|
||||
) : (
|
||||
'-'
|
||||
<Text size="sm" c="dimmed">-</Text>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useEffect, useState } from 'react';
|
||||
import colors from '@/con/colors';
|
||||
import { ModalKonfirmasiHapus } from '../../../_com/modalKonfirmasiHapus';
|
||||
import apbdes from '../../../_state/landing-page/apbdes';
|
||||
import RealisasiManager from './RealisasiManager';
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +95,7 @@ function DetailAPBDes() {
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Nama APBDes</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.name || '-'}
|
||||
{data.name || `APBDes Tahun ${data.tahun}`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -105,6 +106,24 @@ function DetailAPBDes() {
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{data.deskripsi && (
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Deskripsi</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.deskripsi}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{data.jumlah && (
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Jumlah Total</Text>
|
||||
<Text fz="md" c="dimmed">
|
||||
{data.jumlah}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box>
|
||||
<Text fz="lg" fw="bold">Gambar</Text>
|
||||
{data.image?.link ? (
|
||||
@@ -173,48 +192,60 @@ function DetailAPBDes() {
|
||||
|
||||
{/* Tabel Items */}
|
||||
{data.items && data.items.length > 0 ? (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Text fz="lg" fw="bold" mb="sm">
|
||||
<Stack gap="md">
|
||||
<Text fz="lg" fw="bold">
|
||||
Rincian Pendapatan & Belanja ({data.items.length} item)
|
||||
</Text>
|
||||
<Box style={{ overflowX: 'auto' }}>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Uraian</TableTh>
|
||||
<TableTh>Anggaran (Rp)</TableTh>
|
||||
<TableTh>Realisasi (Rp)</TableTh>
|
||||
<TableTh>Selisih (Rp)</TableTh>
|
||||
<TableTh>Persentase (%)</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{[...data.items] // Create a new array before sorting
|
||||
.sort((a, b) => a.kode.localeCompare(b.kode))
|
||||
.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={getIndent(item.level)}>
|
||||
<Group>
|
||||
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
||||
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
||||
<TableTd>{item.realisasi.toLocaleString('id-ID')}</TableTd>
|
||||
<TableTd>
|
||||
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
||||
{item.selisih.toLocaleString('id-ID')}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
</Paper>
|
||||
<Table striped highlightOnHover>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh>Uraian</TableTh>
|
||||
<TableTh>Anggaran (Rp)</TableTh>
|
||||
<TableTh>Realisasi (Rp)</TableTh>
|
||||
<TableTh>Selisih (Rp)</TableTh>
|
||||
<TableTh>Persentase (%)</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{[...data.items]
|
||||
.sort((a, b) => a.kode.localeCompare(b.kode))
|
||||
.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd style={getIndent(item.level)}>
|
||||
<Group>
|
||||
<Text fw={item.level === 1 ? 'bold' : 'normal'}>{item.kode}</Text>
|
||||
<Text fz="sm" c="dimmed">{item.uraian}</Text>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd>{item.anggaran.toLocaleString('id-ID')}</TableTd>
|
||||
<TableTd>{item.totalRealisasi.toLocaleString('id-ID')}</TableTd>
|
||||
<TableTd>
|
||||
<Text c={item.selisih >= 0 ? 'green' : 'red'}>
|
||||
{item.selisih.toLocaleString('id-ID')}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fw={500}>{item.persentase.toFixed(2)}%</Text>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
|
||||
{/* Realisasi Manager untuk setiap item */}
|
||||
{data.items.map((item) => (
|
||||
<RealisasiManager
|
||||
key={item.id}
|
||||
itemId={item.id}
|
||||
itemKode={item.kode}
|
||||
itemUraian={item.uraian}
|
||||
itemAnggaran={item.anggaran}
|
||||
itemTotalRealisasi={item.totalRealisasi}
|
||||
itemPersentase={item.persentase}
|
||||
realisasiItems={item.realisasiItems || []}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text>Belum ada data item</Text>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,6 @@ type ItemForm = {
|
||||
kode: string;
|
||||
uraian: string;
|
||||
anggaran: number;
|
||||
realisasi: number;
|
||||
level: number;
|
||||
tipe: 'pendapatan' | 'belanja' | 'pembiayaan';
|
||||
};
|
||||
@@ -61,7 +60,6 @@ function CreateAPBDes() {
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
@@ -80,7 +78,6 @@ function CreateAPBDes() {
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
@@ -117,9 +114,9 @@ function CreateAPBDes() {
|
||||
toast.success("Berhasil menambahkan APBDes");
|
||||
resetForm();
|
||||
router.push("/admin/landing-page/apbdes");
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
console.error("Gagal submit:", error);
|
||||
toast.error("Gagal menyimpan data");
|
||||
toast.error(error?.message || "Gagal menyimpan data");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
@@ -127,22 +124,17 @@ function CreateAPBDes() {
|
||||
|
||||
// Tambahkan item ke state
|
||||
const handleAddItem = () => {
|
||||
const { kode, uraian, anggaran, realisasi, level, tipe } = newItem;
|
||||
const { kode, uraian, anggaran, level, tipe } = newItem;
|
||||
if (!kode || !uraian) {
|
||||
return toast.warn("Kode dan uraian wajib diisi");
|
||||
}
|
||||
|
||||
const finalTipe = level === 1 ? null : tipe;
|
||||
const selisih = realisasi - anggaran;
|
||||
const persentase = anggaran > 0 ? (realisasi / anggaran) * 100 : 0;
|
||||
|
||||
stateAPBDes.create.addItem({
|
||||
kode,
|
||||
uraian,
|
||||
anggaran,
|
||||
realisasi,
|
||||
selisih,
|
||||
persentase,
|
||||
level,
|
||||
tipe: finalTipe,
|
||||
});
|
||||
@@ -152,7 +144,6 @@ function CreateAPBDes() {
|
||||
kode: '',
|
||||
uraian: '',
|
||||
anggaran: 0,
|
||||
realisasi: 0,
|
||||
level: 1,
|
||||
tipe: 'pendapatan',
|
||||
});
|
||||
@@ -334,6 +325,27 @@ function CreateAPBDes() {
|
||||
</Stack>
|
||||
|
||||
{/* Form Header */}
|
||||
<TextInput
|
||||
label="Nama APBDes"
|
||||
placeholder="Contoh: APBDes Tahun 2025"
|
||||
value={stateAPBDes.create.form.name}
|
||||
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)}
|
||||
description="Opsional - akan diisi otomatis jika kosong"
|
||||
/>
|
||||
<TextInput
|
||||
label="Deskripsi"
|
||||
placeholder="Deskripsi APBDes (opsional)"
|
||||
value={stateAPBDes.create.form.deskripsi}
|
||||
onChange={(e) => (stateAPBDes.create.form.deskripsi = e.target.value)}
|
||||
description="Opsional"
|
||||
/>
|
||||
<TextInput
|
||||
label="Jumlah Total"
|
||||
placeholder="Contoh: Rp 1.000.000.000"
|
||||
value={stateAPBDes.create.form.jumlah}
|
||||
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)}
|
||||
description="Opsional - total keseluruhan anggaran"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Tahun"
|
||||
value={stateAPBDes.create.form.tahun || new Date().getFullYear()}
|
||||
@@ -406,13 +418,6 @@ function CreateAPBDes() {
|
||||
thousandSeparator
|
||||
min={0}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Realisasi (Rp)"
|
||||
value={newItem.realisasi}
|
||||
onChange={(val) => setNewItem({ ...newItem, realisasi: Number(val) || 0 })}
|
||||
thousandSeparator
|
||||
min={0}
|
||||
/>
|
||||
</Group>
|
||||
<Button
|
||||
leftSection={<IconPlus size={16} />}
|
||||
@@ -434,28 +439,30 @@ function CreateAPBDes() {
|
||||
<th>Kode</th>
|
||||
<th>Uraian</th>
|
||||
<th>Anggaran</th>
|
||||
<th>Realisasi</th>
|
||||
<th>Level</th>
|
||||
<th>Tipe</th>
|
||||
<th style={{ width: 50 }}>Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{stateAPBDes.create.form.items.map((item, idx) => (
|
||||
{stateAPBDes.create.form.items.map((item: any, idx) => (
|
||||
<tr key={idx}>
|
||||
<td><Text size="sm" fw={500}>{item.kode}</Text></td>
|
||||
<td>{item.uraian}</td>
|
||||
<td>{item.anggaran.toLocaleString('id-ID')}</td>
|
||||
<td>{item.realisasi.toLocaleString('id-ID')}</td>
|
||||
<td>
|
||||
<Badge size="sm" color={item.level === 1 ? 'blue' : item.level === 2 ? 'green' : 'grape'}>
|
||||
L{item.level}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
||||
{item.tipe}
|
||||
</Badge>
|
||||
{item.tipe ? (
|
||||
<Badge size="sm" color={item.tipe === 'pendapatan' ? 'teal' : 'red'}>
|
||||
{item.tipe}
|
||||
</Badge>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">-</Text>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<ActionIcon color="red" onClick={() => handleRemoveItem(idx)}>
|
||||
|
||||
@@ -45,7 +45,7 @@ function APBDes() {
|
||||
function ListAPBDes({ search }: { search: string }) {
|
||||
const listState = useProxy(apbdes);
|
||||
const router = useRouter();
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const { data, page, totalPages, loading, load } = listState.findMany;
|
||||
|
||||
|
||||
428
src/app/admin/(dashboard)/musik/[id]/edit/page.tsx
Normal file
428
src/app/admin/(dashboard)/musik/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,428 @@
|
||||
'use client'
|
||||
import CreateEditor from '../../../_com/createEditor';
|
||||
import stateDashboardMusik from '../../../_state/desa/musik';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Loader,
|
||||
ActionIcon,
|
||||
NumberInput
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconMusic } from '@tabler/icons-react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
export default function EditMusik() {
|
||||
const musikState = useProxy(stateDashboardMusik);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
|
||||
const [previewCover, setPreviewCover] = useState<string | null>(null);
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||
const [previewAudio, setPreviewAudio] = useState<string | null>(null);
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [isExtractingDuration, setIsExtractingDuration] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Fungsi untuk mendapatkan durasi dari file audio
|
||||
const getAudioDuration = (file: File): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const audio = new Audio();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
const duration = audio.duration;
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = Math.floor(duration % 60);
|
||||
const formatted = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(formatted);
|
||||
});
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve('0:00');
|
||||
});
|
||||
|
||||
audio.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
useShallowEffect(() => {
|
||||
if (id) {
|
||||
musikState.musik.edit.load(id).then(() => setIsLoading(false));
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
musikState.musik.edit.form.judul?.trim() !== '' &&
|
||||
musikState.musik.edit.form.artis?.trim() !== '' &&
|
||||
musikState.musik.edit.form.durasi?.trim() !== '' &&
|
||||
(coverFile !== null || musikState.musik.edit.form.coverImageId !== '') &&
|
||||
(audioFile !== null || musikState.musik.edit.form.audioFileId !== '')
|
||||
);
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
musikState.musik.edit.reset();
|
||||
setPreviewCover(null);
|
||||
setCoverFile(null);
|
||||
setPreviewAudio(null);
|
||||
setAudioFile(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!musikState.musik.edit.form.judul?.trim()) {
|
||||
toast.error('Judul wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!musikState.musik.edit.form.artis?.trim()) {
|
||||
toast.error('Artis wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!musikState.musik.edit.form.durasi?.trim()) {
|
||||
toast.error('Durasi wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Upload cover image if new file selected
|
||||
if (coverFile) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file: coverFile,
|
||||
name: coverFile.name,
|
||||
});
|
||||
|
||||
const uploaded = res.data?.data;
|
||||
if (!uploaded?.id) {
|
||||
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||
}
|
||||
|
||||
musikState.musik.edit.form.coverImageId = uploaded.id;
|
||||
}
|
||||
|
||||
// Upload audio file if new file selected
|
||||
if (audioFile) {
|
||||
const res = await ApiFetch.api.fileStorage.create.post({
|
||||
file: audioFile,
|
||||
name: audioFile.name,
|
||||
});
|
||||
|
||||
const uploaded = res.data?.data;
|
||||
if (!uploaded?.id) {
|
||||
return toast.error('Gagal mengunggah audio, silakan coba lagi');
|
||||
}
|
||||
|
||||
musikState.musik.edit.form.audioFileId = uploaded.id;
|
||||
}
|
||||
|
||||
await musikState.musik.edit.update();
|
||||
|
||||
resetForm();
|
||||
router.push('/admin/musik');
|
||||
} catch (error) {
|
||||
console.error('Error updating musik:', error);
|
||||
toast.error('Terjadi kesalahan saat mengupdate musik');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xl">
|
||||
<Center>
|
||||
<Loader />
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header dengan tombol kembali */}
|
||||
<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 Musik
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<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"
|
||||
placeholder="Masukkan judul lagu"
|
||||
value={musikState.musik.edit.form.judul}
|
||||
onChange={(e) => (musikState.musik.edit.form.judul = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Artis"
|
||||
placeholder="Masukkan nama artis"
|
||||
value={musikState.musik.edit.form.artis}
|
||||
onChange={(e) => (musikState.musik.edit.form.artis = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>
|
||||
Deskripsi
|
||||
</Text>
|
||||
<CreateEditor
|
||||
value={musikState.musik.edit.form.deskripsi}
|
||||
onChange={(htmlContent) => {
|
||||
musikState.musik.edit.form.deskripsi = htmlContent;
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group gap="md">
|
||||
<TextInput
|
||||
label="Durasi"
|
||||
placeholder="Contoh: 3:45"
|
||||
value={musikState.musik.edit.form.durasi}
|
||||
onChange={(e) => (musikState.musik.edit.form.durasi = e.target.value)}
|
||||
required
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Genre"
|
||||
placeholder="Contoh: Pop, Rock, Jazz"
|
||||
value={musikState.musik.edit.form.genre}
|
||||
onChange={(e) => (musikState.musik.edit.form.genre = e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<NumberInput
|
||||
label="Tahun Rilis"
|
||||
placeholder="Contoh: 2024"
|
||||
value={musikState.musik.edit.form.tahunRilis}
|
||||
onChange={(val) => (musikState.musik.edit.form.tahunRilis = val as number | undefined)}
|
||||
min={1900}
|
||||
max={new Date().getFullYear() + 1}
|
||||
/>
|
||||
|
||||
{/* Cover Image */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Cover Image
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setCoverFile(selectedFile);
|
||||
setPreviewCover(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>
|
||||
|
||||
{(previewCover || musikState.musik.edit.form.coverImageId) && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewCover || '/api/placeholder/200/200'}
|
||||
alt="Preview Cover"
|
||||
radius="md"
|
||||
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={() => {
|
||||
setPreviewCover(null);
|
||||
setCoverFile(null);
|
||||
musikState.musik.edit.form.coverImageId = '';
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Audio File */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
File Audio
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={async (files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setAudioFile(selectedFile);
|
||||
setPreviewAudio(selectedFile.name);
|
||||
|
||||
// Extract durasi otomatis dari audio
|
||||
setIsExtractingDuration(true);
|
||||
try {
|
||||
const duration = await getAudioDuration(selectedFile);
|
||||
musikState.musik.edit.form.durasi = duration;
|
||||
toast.success(`Durasi audio terdeteksi: ${duration}`);
|
||||
} catch (error) {
|
||||
console.error('Error extracting audio duration:', error);
|
||||
toast.error('Gagal mendeteksi durasi audio, silakan isi manual');
|
||||
} finally {
|
||||
setIsExtractingDuration(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')}
|
||||
maxSize={50 * 1024 ** 2}
|
||||
accept={{ 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a'] }}
|
||||
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>
|
||||
<IconMusic size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||
Seret file audio atau klik untuk memilih file (maks 50MB)
|
||||
</Text>
|
||||
</Dropzone>
|
||||
|
||||
{(previewAudio || musikState.musik.edit.form.audioFileId) && (
|
||||
<Box mt="sm">
|
||||
<Card p="sm" withBorder>
|
||||
<Group gap="sm">
|
||||
<IconMusic size={20} color={colors['blue-button']} />
|
||||
<Text fz="sm" truncate style={{ flex: 1 }}>
|
||||
{previewAudio || 'File audio tersimpan'}
|
||||
</Text>
|
||||
{isExtractingDuration && (
|
||||
<Text fz="xs" c="blue">
|
||||
Mendeteksi durasi...
|
||||
</Text>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPreviewAudio(null);
|
||||
setAudioFile(null);
|
||||
musikState.musik.edit.form.audioFileId = '';
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
style={{
|
||||
background: !isFormValid() || isSubmitting
|
||||
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||
: `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" /> : 'Update'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
271
src/app/admin/(dashboard)/musik/[id]/page.tsx
Normal file
271
src/app/admin/(dashboard)/musik/[id]/page.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Image,
|
||||
Modal,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title
|
||||
} 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 stateDashboardMusik from '../../_state/desa/musik';
|
||||
|
||||
export default function DetailMusik() {
|
||||
const musikState = useProxy(stateDashboardMusik);
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const id = params.id as string;
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const { data, loading, load } = musikState.musik.findUnique;
|
||||
|
||||
useShallowEffect(() => {
|
||||
if (id) {
|
||||
load(id);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
<Stack>
|
||||
<Skeleton height={50} radius="md" />
|
||||
<Skeleton height={400} radius="md" />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xl">
|
||||
<Center>
|
||||
<Text c="dimmed">Musik tidak ditemukan</Text>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
setIsDeleting(true);
|
||||
await musikState.musik.delete.byId(id);
|
||||
setShowDeleteModal(false);
|
||||
router.push('/admin/musik');
|
||||
} catch (error) {
|
||||
console.error('Error deleting musik:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header dengan tombol kembali */}
|
||||
<Group mb="md">
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => router.push('/admin/musik')}
|
||||
p="xs"
|
||||
radius="md"
|
||||
>
|
||||
<IconArrowBack color={colors['blue-button']} size={24} />
|
||||
</Button>
|
||||
<Title order={4} ml="sm" c="dark">
|
||||
Detail Musik
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<Paper
|
||||
w={{ base: '100%', md: '50%' }}
|
||||
bg={colors['white-1']}
|
||||
p="lg"
|
||||
radius="md"
|
||||
shadow="sm"
|
||||
style={{ border: '1px solid #e0e0e0' }}
|
||||
>
|
||||
<Stack gap="md">
|
||||
{/* Cover Image */}
|
||||
{data.coverImage && (
|
||||
<Box
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 400,
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={data.coverImage.link}
|
||||
alt={data.judul}
|
||||
radius="md"
|
||||
style={{
|
||||
width: '100%',
|
||||
aspectRatio: '1/1',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Info Section */}
|
||||
<Stack gap="sm">
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} c="dimmed">
|
||||
Judul
|
||||
</Text>
|
||||
<Text fz="md" fw={600}>
|
||||
{data.judul}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} c="dimmed">
|
||||
Artis
|
||||
</Text>
|
||||
<Text fz="md" fw={500}>
|
||||
{data.artis}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{data.deskripsi && (
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} c="dimmed">
|
||||
Deskripsi
|
||||
</Text>
|
||||
<Text fz="sm" fw={500} dangerouslySetInnerHTML={{ __html: data.deskripsi }} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Group gap="xl">
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} c="dimmed">
|
||||
Durasi
|
||||
</Text>
|
||||
<Text fz="md" fw={500}>
|
||||
{data.durasi}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{data.genre && (
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} c="dimmed">
|
||||
Genre
|
||||
</Text>
|
||||
<Text fz="md" fw={500}>
|
||||
{data.genre}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{data.tahunRilis && (
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} c="dimmed">
|
||||
Tahun Rilis
|
||||
</Text>
|
||||
<Text fz="md" fw={500}>
|
||||
{data.tahunRilis}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Audio File */}
|
||||
{data.audioFile && (
|
||||
<Box>
|
||||
<Text fz="sm" fw={600} c="dimmed">
|
||||
File Audio
|
||||
</Text>
|
||||
<Card mt="xs" p="sm" withBorder>
|
||||
<Group gap="sm">
|
||||
<Text fz="sm" truncate style={{ flex: 1 }}>
|
||||
{data.audioFile.realName}
|
||||
</Text>
|
||||
<Button
|
||||
component="a"
|
||||
href={data.audioFile.link}
|
||||
target="_blank"
|
||||
variant="light"
|
||||
size="sm"
|
||||
>
|
||||
Putar
|
||||
</Button>
|
||||
</Group>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<Group justify="right" mt="md">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<IconTrash size={18} />}
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
Hapus
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
color="blue"
|
||||
radius="md"
|
||||
size="md"
|
||||
leftSection={<IconEdit size={18} />}
|
||||
onClick={() => router.push(`/admin/musik/${id}/edit`)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
<Modal
|
||||
opened={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
title="Konfirmasi Hapus"
|
||||
centered
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text>
|
||||
Apakah Anda yakin ingin menghapus musik "{data.judul}"?
|
||||
</Text>
|
||||
<Text c="red" fz="sm">
|
||||
Tindakan ini tidak dapat dibatalkan.
|
||||
</Text>
|
||||
<Group justify="right" mt="md">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
onClick={() => setShowDeleteModal(false)}
|
||||
>
|
||||
Batal
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
onClick={handleDelete}
|
||||
loading={isDeleting}
|
||||
>
|
||||
Hapus
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
426
src/app/admin/(dashboard)/musik/create/page.tsx
Normal file
426
src/app/admin/(dashboard)/musik/create/page.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
'use client'
|
||||
import CreateEditor from '../../_com/createEditor';
|
||||
import stateDashboardMusik from '../../_state/desa/musik';
|
||||
import colors from '@/con/colors';
|
||||
import ApiFetch from '@/lib/api-fetch';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Image,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Loader,
|
||||
ActionIcon,
|
||||
NumberInput
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useShallowEffect } from '@mantine/hooks';
|
||||
import { IconArrowBack, IconPhoto, IconUpload, IconX, IconMusic } from '@tabler/icons-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useProxy } from 'valtio/utils';
|
||||
|
||||
export default function CreateMusik() {
|
||||
const musikState = useProxy(stateDashboardMusik);
|
||||
const [previewCover, setPreviewCover] = useState<string | null>(null);
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||
const [previewAudio, setPreviewAudio] = useState<string | null>(null);
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
||||
const [isExtractingDuration, setIsExtractingDuration] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Fungsi untuk mendapatkan durasi dari file audio
|
||||
const getAudioDuration = (file: File): Promise<string> => {
|
||||
return new Promise((resolve) => {
|
||||
const audio = new Audio();
|
||||
const url = URL.createObjectURL(file);
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
const duration = audio.duration;
|
||||
const minutes = Math.floor(duration / 60);
|
||||
const seconds = Math.floor(duration % 60);
|
||||
const formatted = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
URL.revokeObjectURL(url);
|
||||
resolve(formatted);
|
||||
});
|
||||
|
||||
audio.addEventListener('error', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
resolve('0:00');
|
||||
});
|
||||
|
||||
audio.src = url;
|
||||
});
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return (
|
||||
musikState.musik.create.form.judul?.trim() !== '' &&
|
||||
musikState.musik.create.form.artis?.trim() !== '' &&
|
||||
musikState.musik.create.form.durasi?.trim() !== '' &&
|
||||
audioFile !== null &&
|
||||
coverFile !== null
|
||||
);
|
||||
};
|
||||
|
||||
useShallowEffect(() => {
|
||||
return () => {
|
||||
musikState.musik.create.resetForm();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetForm = () => {
|
||||
musikState.musik.create.form = {
|
||||
judul: '',
|
||||
artis: '',
|
||||
deskripsi: '',
|
||||
durasi: '',
|
||||
audioFileId: '',
|
||||
coverImageId: '',
|
||||
genre: '',
|
||||
tahunRilis: undefined,
|
||||
};
|
||||
setPreviewCover(null);
|
||||
setCoverFile(null);
|
||||
setPreviewAudio(null);
|
||||
setAudioFile(null);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!musikState.musik.create.form.judul?.trim()) {
|
||||
toast.error('Judul wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!musikState.musik.create.form.artis?.trim()) {
|
||||
toast.error('Artis wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!musikState.musik.create.form.durasi?.trim()) {
|
||||
toast.error('Durasi wajib diisi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!coverFile) {
|
||||
toast.error('Cover image wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!audioFile) {
|
||||
toast.error('File audio wajib dipilih');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
// Upload cover image
|
||||
const coverRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: coverFile,
|
||||
name: coverFile.name,
|
||||
});
|
||||
|
||||
const coverUploaded = coverRes.data?.data;
|
||||
if (!coverUploaded?.id) {
|
||||
return toast.error('Gagal mengunggah cover, silakan coba lagi');
|
||||
}
|
||||
|
||||
musikState.musik.create.form.coverImageId = coverUploaded.id;
|
||||
|
||||
// Upload audio file
|
||||
const audioRes = await ApiFetch.api.fileStorage.create.post({
|
||||
file: audioFile,
|
||||
name: audioFile.name,
|
||||
});
|
||||
|
||||
const audioUploaded = audioRes.data?.data;
|
||||
if (!audioUploaded?.id) {
|
||||
return toast.error('Gagal mengunggah audio, silakan coba lagi');
|
||||
}
|
||||
|
||||
musikState.musik.create.form.audioFileId = audioUploaded.id;
|
||||
|
||||
await musikState.musik.create.create();
|
||||
|
||||
resetForm();
|
||||
router.push('/admin/musik');
|
||||
} catch (error) {
|
||||
console.error('Error creating musik:', error);
|
||||
toast.error('Terjadi kesalahan saat membuat musik');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box px={{ base: 0, md: 'lg' }} py="xs">
|
||||
{/* Header dengan tombol kembali */}
|
||||
<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 Musik
|
||||
</Title>
|
||||
</Group>
|
||||
|
||||
<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"
|
||||
placeholder="Masukkan judul lagu"
|
||||
value={musikState.musik.create.form.judul}
|
||||
onChange={(e) => (musikState.musik.create.form.judul = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Artis"
|
||||
placeholder="Masukkan nama artis"
|
||||
value={musikState.musik.create.form.artis}
|
||||
onChange={(e) => (musikState.musik.create.form.artis = e.target.value)}
|
||||
required
|
||||
/>
|
||||
|
||||
<Box>
|
||||
<Text fz="sm" fw="bold" mb={6}>
|
||||
Deskripsi
|
||||
</Text>
|
||||
<CreateEditor
|
||||
value={musikState.musik.create.form.deskripsi}
|
||||
onChange={(htmlContent) => {
|
||||
musikState.musik.create.form.deskripsi = htmlContent;
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Group gap="md">
|
||||
<TextInput
|
||||
label="Durasi"
|
||||
placeholder="Contoh: 3:45"
|
||||
value={musikState.musik.create.form.durasi}
|
||||
onChange={(e) => (musikState.musik.create.form.durasi = e.target.value)}
|
||||
required
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Genre"
|
||||
placeholder="Contoh: Pop, Rock, Jazz"
|
||||
value={musikState.musik.create.form.genre}
|
||||
onChange={(e) => (musikState.musik.create.form.genre = e.target.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<NumberInput
|
||||
label="Tahun Rilis"
|
||||
placeholder="Contoh: 2024"
|
||||
value={musikState.musik.create.form.tahunRilis}
|
||||
onChange={(val) => (musikState.musik.create.form.tahunRilis = val as number | undefined)}
|
||||
min={1900}
|
||||
max={new Date().getFullYear() + 1}
|
||||
/>
|
||||
|
||||
{/* Cover Image */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
Cover Image
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={(files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setCoverFile(selectedFile);
|
||||
setPreviewCover(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>
|
||||
|
||||
{previewCover && (
|
||||
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
|
||||
<Image
|
||||
src={previewCover}
|
||||
alt="Preview Cover"
|
||||
radius="md"
|
||||
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={() => {
|
||||
setPreviewCover(null);
|
||||
setCoverFile(null);
|
||||
}}
|
||||
style={{
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Audio File */}
|
||||
<Box>
|
||||
<Text fw="bold" fz="sm" mb={6}>
|
||||
File Audio
|
||||
</Text>
|
||||
<Dropzone
|
||||
onDrop={async (files) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile) {
|
||||
setAudioFile(selectedFile);
|
||||
setPreviewAudio(selectedFile.name);
|
||||
|
||||
// Extract durasi otomatis dari audio
|
||||
setIsExtractingDuration(true);
|
||||
try {
|
||||
const duration = await getAudioDuration(selectedFile);
|
||||
musikState.musik.create.form.durasi = duration;
|
||||
toast.success(`Durasi audio terdeteksi: ${duration}`);
|
||||
} catch (error) {
|
||||
console.error('Error extracting audio duration:', error);
|
||||
toast.error('Gagal mendeteksi durasi audio, silakan isi manual');
|
||||
} finally {
|
||||
setIsExtractingDuration(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
onReject={() => toast.error('File tidak valid, gunakan format audio (MP3, WAV, OGG)')}
|
||||
maxSize={50 * 1024 ** 2}
|
||||
accept={{ 'audio/*': ['.mp3', '.wav', '.ogg', '.m4a'] }}
|
||||
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>
|
||||
<IconMusic size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
|
||||
</Dropzone.Idle>
|
||||
</Group>
|
||||
<Text ta="center" mt="sm" size="sm" color="dimmed">
|
||||
Seret file audio atau klik untuk memilih file (maks 50MB)
|
||||
</Text>
|
||||
</Dropzone>
|
||||
|
||||
{previewAudio && (
|
||||
<Box mt="sm">
|
||||
<Card p="sm" withBorder>
|
||||
<Group gap="sm">
|
||||
<IconMusic size={20} color={colors['blue-button']} />
|
||||
<Text fz="sm" truncate style={{ flex: 1 }}>
|
||||
{previewAudio}
|
||||
</Text>
|
||||
{isExtractingDuration && (
|
||||
<Text fz="xs" c="blue">
|
||||
Mendeteksi durasi...
|
||||
</Text>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="red"
|
||||
radius="xl"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setPreviewAudio(null);
|
||||
setAudioFile(null);
|
||||
}}
|
||||
>
|
||||
<IconX size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Group justify="right">
|
||||
<Button
|
||||
variant="outline"
|
||||
color="gray"
|
||||
radius="md"
|
||||
size="md"
|
||||
onClick={resetForm}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
radius="md"
|
||||
size="md"
|
||||
disabled={!isFormValid() || isSubmitting}
|
||||
style={{
|
||||
background: !isFormValid() || isSubmitting
|
||||
? `linear-gradient(135deg, #cccccc, #eeeeee)`
|
||||
: `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>
|
||||
);
|
||||
}
|
||||
231
src/app/admin/(dashboard)/musik/page.tsx
Normal file
231
src/app/admin/(dashboard)/musik/page.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
import colors from '@/con/colors';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Pagination,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
TableTbody,
|
||||
TableTd,
|
||||
TableTh,
|
||||
TableThead,
|
||||
TableTr,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
|
||||
import { IconCircleDashedPlus, IconDeviceImacCog, 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 stateDashboardMusik from '../_state/desa/musik';
|
||||
|
||||
|
||||
function Musik() {
|
||||
const [search, setSearch] = useState("");
|
||||
return (
|
||||
<Box>
|
||||
<HeaderSearch
|
||||
title="Musik Desa"
|
||||
placeholder="Cari judul, artis, atau genre..."
|
||||
searchIcon={<IconSearch size={20} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
/>
|
||||
<ListMusik search={search} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ListMusik({ search }: { search: string }) {
|
||||
const musikState = useProxy(stateDashboardMusik);
|
||||
const router = useRouter();
|
||||
const [debouncedSearch] = useDebouncedValue(search, 1000);
|
||||
|
||||
const { data, page, totalPages, loading, load } = musikState.musik.findMany;
|
||||
|
||||
useShallowEffect(() => {
|
||||
load(page, 10, debouncedSearch);
|
||||
}, [page, debouncedSearch]);
|
||||
|
||||
if (loading || !data) {
|
||||
return (
|
||||
<Stack py="md">
|
||||
<Skeleton height={600} radius="md" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const filteredData = data || [];
|
||||
|
||||
return (
|
||||
<Box py="md">
|
||||
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={4}>Daftar Musik</Title>
|
||||
<Button
|
||||
leftSection={<IconCircleDashedPlus size={18} />}
|
||||
color="blue"
|
||||
variant="light"
|
||||
onClick={() => router.push('/admin/musik/create')}
|
||||
>
|
||||
Tambah Baru
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Desktop Table */}
|
||||
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
|
||||
<Table highlightOnHover
|
||||
layout="fixed"
|
||||
withColumnBorders={false} miw={0}>
|
||||
<TableThead>
|
||||
<TableTr>
|
||||
<TableTh w="30%">Judul</TableTh>
|
||||
<TableTh w="20%">Artis</TableTh>
|
||||
<TableTh w="15%">Durasi</TableTh>
|
||||
<TableTh w="15%">Genre</TableTh>
|
||||
<TableTh w="20%">Aksi</TableTh>
|
||||
</TableTr>
|
||||
</TableThead>
|
||||
<TableTbody>
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<TableTr key={item.id}>
|
||||
<TableTd>
|
||||
<Text fz="md" fw={600} lh={1.45} truncate="end">
|
||||
{item.judul}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||
{item.artis}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||
{item.durasi}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Text fz="sm" c="dimmed" lh={1.45}>
|
||||
{item.genre || '-'}
|
||||
</Text>
|
||||
</TableTd>
|
||||
<TableTd>
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() =>
|
||||
router.push(`/admin/musik/${item.id}`)
|
||||
}
|
||||
fz="sm"
|
||||
px="sm"
|
||||
h={36}
|
||||
>
|
||||
<IconDeviceImacCog size={18} />
|
||||
<Text ml="xs">Detail</Text>
|
||||
</Button>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
))
|
||||
) : (
|
||||
<TableTr>
|
||||
<TableTd colSpan={5}>
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data musik yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
)}
|
||||
</TableTbody>
|
||||
</Table>
|
||||
</Box>
|
||||
|
||||
{/* Mobile Cards */}
|
||||
<Stack hiddenFrom="md" gap="sm" mt="sm">
|
||||
{filteredData.length > 0 ? (
|
||||
filteredData.map((item) => (
|
||||
<Paper key={item.id} withBorder p="md" radius="md">
|
||||
<Stack gap={"xs"}>
|
||||
<Text fz="sm" fw={600} lh={1.4} c="dimmed">
|
||||
Judul
|
||||
</Text>
|
||||
<Text fz="sm" fw={500} lh={1.45}>
|
||||
{item.judul}
|
||||
</Text>
|
||||
|
||||
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||
Artis
|
||||
</Text>
|
||||
<Text fz="sm" lh={1.45} fw={500}>
|
||||
{item.artis}
|
||||
</Text>
|
||||
|
||||
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||
Durasi
|
||||
</Text>
|
||||
<Text fz="sm" lh={1.45} fw={500}>
|
||||
{item.durasi}
|
||||
</Text>
|
||||
|
||||
<Text fz="sm" fw={600} lh={1.4} c="dimmed" mt="xs">
|
||||
Genre
|
||||
</Text>
|
||||
<Text fz="sm" lh={1.45} fw={500}>
|
||||
{item.genre || '-'}
|
||||
</Text>
|
||||
|
||||
<Button
|
||||
variant="light"
|
||||
color="blue"
|
||||
fullWidth
|
||||
mt="sm"
|
||||
onClick={() =>
|
||||
router.push(`/admin/musik/${item.id}`)
|
||||
}
|
||||
fz="sm"
|
||||
h={36}
|
||||
>
|
||||
<IconDeviceImacCog size={18} />
|
||||
<Text ml="xs">Detail</Text>
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))
|
||||
) : (
|
||||
<Center py="xl">
|
||||
<Text c="dimmed" fz="sm" lh={1.4}>
|
||||
Tidak ada data musik yang cocok
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Center>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(newPage) => {
|
||||
load(newPage, 10, debouncedSearch);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}}
|
||||
total={totalPages}
|
||||
mt="md"
|
||||
mb="md"
|
||||
color="blue"
|
||||
radius="md"
|
||||
/>
|
||||
</Center>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Musik;
|
||||
@@ -330,7 +330,7 @@ export const devBar = [
|
||||
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Pendidikan",
|
||||
name: "Pendidikan",
|
||||
@@ -373,6 +373,11 @@ export const devBar = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Musik",
|
||||
name: "Musik",
|
||||
path: "/admin/musik"
|
||||
},
|
||||
{
|
||||
id: "User & Role",
|
||||
name: "User & Role",
|
||||
@@ -729,7 +734,7 @@ export const navBar = [
|
||||
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Pendidikan",
|
||||
name: "Pendidikan",
|
||||
@@ -772,6 +777,11 @@ export const navBar = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Musik",
|
||||
name: "Musik",
|
||||
path: "/admin/musik"
|
||||
},
|
||||
{
|
||||
id: "User & Role",
|
||||
name: "User & Role",
|
||||
@@ -1051,7 +1061,7 @@ export const role1 = [
|
||||
}
|
||||
|
||||
]
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Lingkungan",
|
||||
name: "Lingkungan",
|
||||
@@ -1088,6 +1098,11 @@ export const role1 = [
|
||||
path: "/admin/lingkungan/konservasi-adat-bali/filosofi-tri-hita-karana"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Musik",
|
||||
name: "Musik",
|
||||
path: "/admin/musik"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1133,6 +1148,11 @@ export const role2 = [
|
||||
path: "/admin/kesehatan/info-wabah-penyakit"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Musik",
|
||||
name: "Musik",
|
||||
path: "/admin/musik"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1178,5 +1198,10 @@ export const role3 = [
|
||||
path: "/admin/pendidikan/data-pendidikan"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "Musik",
|
||||
name: "Musik",
|
||||
path: "/admin/musik"
|
||||
}
|
||||
]
|
||||
@@ -316,8 +316,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
variant="light"
|
||||
active={isParentActive}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (v.path) handleNavClick(v.path);
|
||||
}}
|
||||
href={v.path || undefined}
|
||||
>
|
||||
{v.children.map((child, key) => {
|
||||
{v.children?.map((child, key) => {
|
||||
const isChildActive = segments.includes(_.lowerCase(child.name));
|
||||
return (
|
||||
<NavLink
|
||||
|
||||
Reference in New Issue
Block a user