diff --git a/app/(dashboard)/master/satuan-barang/_components/daftar-satuan-barang.tsx b/app/(dashboard)/master/satuan-barang/_components/daftar-satuan-barang.tsx new file mode 100644 index 0000000..bd95d25 --- /dev/null +++ b/app/(dashboard)/master/satuan-barang/_components/daftar-satuan-barang.tsx @@ -0,0 +1,90 @@ +import { PencilIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Pagination } from "@/components/pagination"; +import { getCurrentUser } from "@/data/auth"; +import { listSatuanBarang } from "@/data/satuan-barang"; +import { halamanHref, parsePage } from "@/lib/pagination"; +import { HapusSatuanBarangButton } from "./hapus-satuan-barang-button"; +import { SatuanBarangForm } from "./satuan-barang-form"; + +export async function DaftarSatuanBarang({ + searchParams, +}: { + searchParams: Promise<{ search?: string; page?: string }>; +}) { + await getCurrentUser(); + + const { search, page: pageParam } = await searchParams; + const page = parsePage(pageParam); + const { rows, hasNext } = await listSatuanBarang(search, page); + + return ( + <> +
+
+ + + + + + + + + + {rows.map((s) => ( + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
IDNama SatuanAksi
+ {s.idSatuanBarang} + + {s.namaSatuanBarang} + +
+ + + Edit + + } + /> + +
+
+ {page > 1 ? "Halaman ini kosong" : "Tidak ada satuan"} +
+
+
+ halamanHref("/master/satuan-barang", p, search)} + /> + + ); +} diff --git a/app/(dashboard)/master/satuan-barang/_components/hapus-satuan-barang-button.tsx b/app/(dashboard)/master/satuan-barang/_components/hapus-satuan-barang-button.tsx new file mode 100644 index 0000000..417fb87 --- /dev/null +++ b/app/(dashboard)/master/satuan-barang/_components/hapus-satuan-barang-button.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { Loader2Icon, Trash2Icon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { hapusSatuanBarang, type HapusSatuanBarangState } from "../actions"; + +type HapusSatuanBarangButtonProps = { + idSatuanBarang: number; + nama: string; +}; + +const initial: HapusSatuanBarangState = { ok: false }; + +export function HapusSatuanBarangButton({ + idSatuanBarang, + nama, +}: HapusSatuanBarangButtonProps) { + const [open, setOpen] = useState(false); + const [state, formAction, pending] = useActionState( + async (prevState: HapusSatuanBarangState, formData: FormData) => { + const result = await hapusSatuanBarang(prevState, formData); + if (result.ok) setOpen(false); + return result; + }, + initial, + ); + + return ( + + + } + > + + Hapus + + + + + + Hapus {nama || `satuan ${idSatuanBarang}`}? + + + Data satuan {idSatuanBarang} akan dihapus permanen. + + + +
+ + {state.error && ( +

{state.error}

+ )} + + Batal + + +
+
+
+ ); +} diff --git a/app/(dashboard)/master/satuan-barang/_components/satuan-barang-form.tsx b/app/(dashboard)/master/satuan-barang/_components/satuan-barang-form.tsx new file mode 100644 index 0000000..945883b --- /dev/null +++ b/app/(dashboard)/master/satuan-barang/_components/satuan-barang-form.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useActionState, useState } from "react"; +import { Loader2Icon } from "lucide-react"; + +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Field, FieldLabel, FieldError } from "@/components/ui/field"; +import type { SatuanBarang } from "@/data/satuan-barang"; +import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions"; + +type SatuanBarangFormProps = { + satuan?: SatuanBarang; + trigger: React.ReactElement; +}; + +const initial: SimpanSatuanBarangState = { ok: false }; + +export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) { + const [open, setOpen] = useState(false); + const [state, formAction, pending] = useActionState( + async (prevState: SimpanSatuanBarangState, formData: FormData) => { + const result = await simpanSatuanBarang(prevState, formData); + if (result.ok) setOpen(false); + return result; + }, + initial, + ); + + const errors = state.fieldErrors?.namaSatuanBarang; + + return ( + + + + + + {satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"} + + + {satuan + ? `ID: ${satuan.idSatuanBarang}` + : "Isi nama satuan barang baru"} + + + +
+ {satuan && ( + + )} + + + Nama Satuan * + + ({ message }))} /> + + + {state.error && ( +

{state.error}

+ )} + + + + +
+
+
+ ); +} diff --git a/app/(dashboard)/master/satuan-barang/actions.ts b/app/(dashboard)/master/satuan-barang/actions.ts new file mode 100644 index 0000000..839fe83 --- /dev/null +++ b/app/(dashboard)/master/satuan-barang/actions.ts @@ -0,0 +1,121 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getCurrentUser } from "@/data/auth"; +import { + countPemakaianSatuanBarang, + deleteSatuanBarang, + insertSatuanBarang, + updateSatuanBarang, + type SatuanBarangInput, +} from "@/data/satuan-barang"; + +export type SimpanSatuanBarangState = { + ok: boolean; + error?: string; + fieldErrors?: { namaSatuanBarang?: string[] }; + values?: { namaSatuanBarang?: string }; + idSatuanBarang?: number; +}; + +export type HapusSatuanBarangState = { ok: boolean; error?: string }; + +const satuanBarangSchema = z.object({ + namaSatuanBarang: z + .string() + .trim() + .min(1, "Nama satuan wajib diisi") + .max(30, "Maksimal 30 karakter"), +}); + +export async function simpanSatuanBarang( + _prevState: SimpanSatuanBarangState, + formData: FormData, +): Promise { + const user = await getCurrentUser(); + const values = { + namaSatuanBarang: String(formData.get("namaSatuanBarang") ?? ""), + }; + + const parsed = satuanBarangSchema.safeParse(values); + if (!parsed.success) { + return { + ok: false, + fieldErrors: z.flattenError(parsed.error).fieldErrors, + values, + }; + } + + const input: SatuanBarangInput = parsed.data; + const idRaw = String(formData.get("idSatuanBarang") ?? ""); + const id = idRaw ? Number.parseInt(idRaw, 10) : null; + if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) { + return { ok: false, error: "ID satuan tidak valid", values }; + } + + try { + if (id !== null) { + const updated = await updateSatuanBarang(id, input); + if (!updated) { + return { ok: false, error: "Satuan tidak ditemukan", values }; + } + revalidatePath("/master/satuan-barang"); + return { ok: true, idSatuanBarang: id }; + } + + const idBaru = await insertSatuanBarang(input); + revalidatePath("/master/satuan-barang"); + return { ok: true, idSatuanBarang: idBaru }; + } catch (error) { + console.error( + "[simpanSatuanBarang] gagal", + { + userId: user.id, + idSatuanBarang: id ?? "(baru)", + nama: input.namaSatuanBarang, + }, + error, + ); + return { + ok: false, + error: "Gagal menyimpan satuan, coba simpan ulang", + values, + }; + } +} + +export async function hapusSatuanBarang( + _prevState: HapusSatuanBarangState, + formData: FormData, +): Promise { + const user = await getCurrentUser(); + + const id = Number.parseInt(String(formData.get("idSatuanBarang") ?? ""), 10); + if (!Number.isSafeInteger(id) || id <= 0) { + return { ok: false, error: "ID satuan tidak ada" }; + } + + try { + const dipakai = await countPemakaianSatuanBarang(id); + if (dipakai > 0) { + return { + ok: false, + error: `Satuan masih dipakai ${dipakai} barang, tidak bisa dihapus`, + }; + } + + const deleted = await deleteSatuanBarang(id); + if (!deleted) { + return { ok: false, error: "Satuan tidak ditemukan" }; + } + revalidatePath("/master/satuan-barang"); + return { ok: true }; + } catch (error) { + console.error("[hapusSatuanBarang] gagal", { userId: user.id, id }, error); + return { + ok: false, + error: "Gagal menghapus satuan, coba hapus ulang", + }; + } +} diff --git a/app/(dashboard)/master/satuan-barang/page.tsx b/app/(dashboard)/master/satuan-barang/page.tsx new file mode 100644 index 0000000..bcbf392 --- /dev/null +++ b/app/(dashboard)/master/satuan-barang/page.tsx @@ -0,0 +1,40 @@ +import { Suspense } from "react"; +import { PlusIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { CariMaster } from "@/components/cari-master"; +import { DaftarSatuanBarang } from "./_components/daftar-satuan-barang"; +import { SatuanBarangForm } from "./_components/satuan-barang-form"; + +export default function SatuanBarangPage({ + searchParams, +}: { + searchParams: Promise<{ search?: string; page?: string }>; +}) { + return ( +
+
+

Data Satuan Barang

+
+ + + + + Tambah + + + } + /> +
+
+ Memuat...

}> + +
+
+ ); +} diff --git a/data/satuan-barang.ts b/data/satuan-barang.ts new file mode 100644 index 0000000..8981298 --- /dev/null +++ b/data/satuan-barang.ts @@ -0,0 +1,93 @@ +import "server-only"; + +import { asc, count, eq, like } from "drizzle-orm"; +import { amigoDb } from "@/db/amigo"; +import { no_barang, satuan_barang } from "@/db/amigo/schema"; +import { PER_PAGE } from "@/lib/pagination"; +import { escapeLike } from "@/lib/utils"; + +export type SatuanBarang = { + idSatuanBarang: number; + namaSatuanBarang: string | null; +}; + +export type SatuanBarangPage = { + rows: SatuanBarang[]; + hasNext: boolean; +}; + +// Kolom DB nullable, tapi isian form wajib string. +export type SatuanBarangInput = { + namaSatuanBarang: NonNullable; +}; + +export async function insertSatuanBarang( + input: SatuanBarangInput, +): Promise { + const [res] = await amigoDb.insert(satuan_barang).values({ + namaSatuanBarang: input.namaSatuanBarang, + }); + + return res.insertId; +} + +export async function updateSatuanBarang( + idSatuanBarang: number, + input: SatuanBarangInput, +): Promise { + const [res] = await amigoDb + .update(satuan_barang) + .set({ namaSatuanBarang: input.namaSatuanBarang }) + .where(eq(satuan_barang.idSatuanBarang, idSatuanBarang)); + + return res.affectedRows > 0; +} + +// `no_barang.satuan` menyimpan idSatuanBarang sebagai varchar. +export async function countPemakaianSatuanBarang( + idSatuanBarang: number, +): Promise { + const [row] = await amigoDb + .select({ total: count() }) + .from(no_barang) + .where(eq(no_barang.satuan, String(idSatuanBarang))); + + return row?.total ?? 0; +} + +export async function deleteSatuanBarang( + idSatuanBarang: number, +): Promise { + const [res] = await amigoDb + .delete(satuan_barang) + .where(eq(satuan_barang.idSatuanBarang, idSatuanBarang)); + + return res.affectedRows > 0; +} + +export async function listSatuanBarang( + search?: string, + page = 1, +): Promise { + const q = search?.trim(); + const pattern = q ? `%${escapeLike(q)}%` : ""; + + const rows = await amigoDb + .select({ + idSatuanBarang: satuan_barang.idSatuanBarang, + namaSatuanBarang: satuan_barang.namaSatuanBarang, + }) + .from(satuan_barang) + .where(q ? like(satuan_barang.namaSatuanBarang, pattern) : undefined) + .orderBy( + asc(satuan_barang.namaSatuanBarang), + asc(satuan_barang.idSatuanBarang), + ) + .limit(PER_PAGE + 1) + .offset((page - 1) * PER_PAGE); + + return { + rows: rows.slice(0, PER_PAGE), + hasNext: rows.length > PER_PAGE, + }; +}