From 5c89d5714fb8131a5e15f5c5e88fbfd8524d8ac0 Mon Sep 17 00:00:00 2001 From: Deardo Satria Date: Mon, 2 Nov 2026 14:28:02 +0700 Subject: [PATCH] Add master rak feature --- app/(dashboard)/_components/nav-items.ts | 1 + .../master/rak/_components/daftar-rak.tsx | 85 ++++++++++++++ .../rak/_components/hapus-rak-button.tsx | 77 +++++++++++++ .../master/rak/_components/rak-form.tsx | 84 ++++++++++++++ app/(dashboard)/master/rak/actions.ts | 107 ++++++++++++++++++ app/(dashboard)/master/rak/page.tsx | 37 ++++++ data/rak.ts | 67 +++++++++++ 7 files changed, 458 insertions(+) create mode 100644 app/(dashboard)/master/rak/_components/daftar-rak.tsx create mode 100644 app/(dashboard)/master/rak/_components/hapus-rak-button.tsx create mode 100644 app/(dashboard)/master/rak/_components/rak-form.tsx create mode 100644 app/(dashboard)/master/rak/actions.ts create mode 100644 app/(dashboard)/master/rak/page.tsx create mode 100644 data/rak.ts diff --git a/app/(dashboard)/_components/nav-items.ts b/app/(dashboard)/_components/nav-items.ts index 28ca6d0..682dbca 100644 --- a/app/(dashboard)/_components/nav-items.ts +++ b/app/(dashboard)/_components/nav-items.ts @@ -22,6 +22,7 @@ export const navItems: NavItem[] = [ items: [ { title: "Satuan Barang", href: "/master/satuan-barang" }, { title: "Data Pelanggan", href: "/master/pelanggan" }, + { title: "Rak", href: "/master/rak" }, ], }, ]; diff --git a/app/(dashboard)/master/rak/_components/daftar-rak.tsx b/app/(dashboard)/master/rak/_components/daftar-rak.tsx new file mode 100644 index 0000000..5188062 --- /dev/null +++ b/app/(dashboard)/master/rak/_components/daftar-rak.tsx @@ -0,0 +1,85 @@ +import { PencilIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Pagination } from "@/components/pagination"; +import { getCurrentUser } from "@/data/auth"; +import { listRak } from "@/data/rak"; +import { halamanHref, parsePage } from "@/lib/pagination"; +import { HapusRakButton } from "./hapus-rak-button"; +import { RakForm } from "./rak-form"; + +export async function DaftarRak({ + searchParams, +}: { + searchParams: Promise<{ search?: string; page?: string }>; +}) { + await getCurrentUser(); + + const { search, page: pageParam } = await searchParams; + const page = parsePage(pageParam); + const { rows, hasNext } = await listRak(search, page); + + return ( + <> +
+
+ + + + + + + + + + {rows.map((r) => ( + + + + + + ))} + {rows.length === 0 && ( + + + + )} + +
IDNama RakAksi
+ {r.idRak} + {r.namaRak} +
+ + + Edit + + } + /> + +
+
+ {page > 1 ? "Halaman ini kosong" : "Tidak ada rak"} +
+
+
+ halamanHref("/master/rak", p, search)} + /> + + ); +} diff --git a/app/(dashboard)/master/rak/_components/hapus-rak-button.tsx b/app/(dashboard)/master/rak/_components/hapus-rak-button.tsx new file mode 100644 index 0000000..dba9f29 --- /dev/null +++ b/app/(dashboard)/master/rak/_components/hapus-rak-button.tsx @@ -0,0 +1,77 @@ +"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 { hapusRak, type HapusRakState } from "../actions"; + +type HapusRakButtonProps = { + idRak: number; + nama: string; +}; + +const initial: HapusRakState = { ok: false }; + +export function HapusRakButton({ idRak, nama }: HapusRakButtonProps) { + const [open, setOpen] = useState(false); + const [state, formAction, pending] = useActionState( + async (prevState: HapusRakState, formData: FormData) => { + const result = await hapusRak(prevState, formData); + if (result.ok) setOpen(false); + return result; + }, + initial, + ); + + return ( + + + } + > + + Hapus + + + + + Hapus {nama || `rak ${idRak}`}? + + Data rak {idRak} akan dihapus permanen. + + + +
+ + {state.error && ( +

{state.error}

+ )} + + Batal + + +
+
+
+ ); +} diff --git a/app/(dashboard)/master/rak/_components/rak-form.tsx b/app/(dashboard)/master/rak/_components/rak-form.tsx new file mode 100644 index 0000000..0c4c6b2 --- /dev/null +++ b/app/(dashboard)/master/rak/_components/rak-form.tsx @@ -0,0 +1,84 @@ +"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 { Rak } from "@/data/rak"; +import { simpanRak, type SimpanRakState } from "../actions"; + +type RakFormProps = { + rak?: Rak; + trigger: React.ReactElement; +}; + +const initial: SimpanRakState = { ok: false }; + +export function RakForm({ rak, trigger }: RakFormProps) { + const [open, setOpen] = useState(false); + const [state, formAction, pending] = useActionState( + async (prevState: SimpanRakState, formData: FormData) => { + const result = await simpanRak(prevState, formData); + if (result.ok) setOpen(false); + return result; + }, + initial, + ); + + const errors = state.fieldErrors?.namaRak; + + return ( + + + + + {rak ? `Edit ${rak.namaRak}` : "Tambah Rak"} + + {rak ? `ID: ${rak.idRak}` : "Isi nama rak baru"} + + + +
+ {rak && } + + + Nama Rak * + + ({ message }))} /> + + + {state.error && ( +

{state.error}

+ )} + + + + +
+
+
+ ); +} diff --git a/app/(dashboard)/master/rak/actions.ts b/app/(dashboard)/master/rak/actions.ts new file mode 100644 index 0000000..dadcf92 --- /dev/null +++ b/app/(dashboard)/master/rak/actions.ts @@ -0,0 +1,107 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getCurrentUser } from "@/data/auth"; +import { deleteRak, insertRak, updateRak, type RakInput } from "@/data/rak"; + +export type SimpanRakState = { + ok: boolean; + error?: string; + fieldErrors?: { namaRak?: string[] }; + values?: { namaRak?: string }; + idRak?: number; +}; + +export type HapusRakState = { ok: boolean; error?: string }; + +const rakSchema = z.object({ + namaRak: z + .string() + .trim() + .min(1, "Nama rak wajib diisi") + .max(30, "Maksimal 30 karakter"), +}); + +export async function simpanRak( + _prevState: SimpanRakState, + formData: FormData, +): Promise { + const user = await getCurrentUser(); + const values = { + namaRak: String(formData.get("namaRak") ?? ""), + }; + + const parsed = rakSchema.safeParse(values); + if (!parsed.success) { + return { + ok: false, + fieldErrors: z.flattenError(parsed.error).fieldErrors, + values, + }; + } + + const input: RakInput = parsed.data; + const idRaw = String(formData.get("idRak") ?? ""); + const id = idRaw ? Number.parseInt(idRaw, 10) : null; + if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) { + return { ok: false, error: "ID rak tidak valid", values }; + } + + try { + if (id !== null) { + const updated = await updateRak(id, input); + if (!updated) { + return { ok: false, error: "Rak tidak ditemukan", values }; + } + revalidatePath("/master/rak"); + return { ok: true, idRak: id }; + } + + const idBaru = await insertRak(input); + revalidatePath("/master/rak"); + return { ok: true, idRak: idBaru }; + } catch (error) { + console.error( + "[simpanRak] gagal", + { + userId: user.id, + idRak: id ?? "(baru)", + nama: input.namaRak, + }, + error, + ); + return { + ok: false, + error: "Gagal menyimpan rak, coba simpan ulang", + values, + }; + } +} + +export async function hapusRak( + _prevState: HapusRakState, + formData: FormData, +): Promise { + const user = await getCurrentUser(); + + const id = Number.parseInt(String(formData.get("idRak") ?? ""), 10); + if (!Number.isSafeInteger(id) || id <= 0) { + return { ok: false, error: "ID rak tidak ada" }; + } + + try { + const deleted = await deleteRak(id); + if (!deleted) { + return { ok: false, error: "Rak tidak ditemukan" }; + } + revalidatePath("/master/rak"); + return { ok: true }; + } catch (error) { + console.error("[hapusRak] gagal", { userId: user.id, id }, error); + return { + ok: false, + error: "Gagal menghapus rak, coba hapus ulang", + }; + } +} diff --git a/app/(dashboard)/master/rak/page.tsx b/app/(dashboard)/master/rak/page.tsx new file mode 100644 index 0000000..fc91448 --- /dev/null +++ b/app/(dashboard)/master/rak/page.tsx @@ -0,0 +1,37 @@ +import { Suspense } from "react"; +import { PlusIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { CariMaster } from "@/components/cari-master"; +import { DaftarRak } from "./_components/daftar-rak"; +import { RakForm } from "./_components/rak-form"; + +export default function RakPage({ + searchParams, +}: { + searchParams: Promise<{ search?: string; page?: string }>; +}) { + return ( +
+
+

Data Rak

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

}> + +
+
+ ); +} diff --git a/data/rak.ts b/data/rak.ts new file mode 100644 index 0000000..e39f6d6 --- /dev/null +++ b/data/rak.ts @@ -0,0 +1,67 @@ +import "server-only"; +import { asc, eq, like } from "drizzle-orm"; +import { amigoDb } from "@/db/amigo"; +import { PER_PAGE } from "@/lib/pagination"; +import { escapeLike } from "@/lib/utils"; +import { rak } from "@/db/amigo/schema"; + +export type Rak = { + idRak: number; + namaRak: string | null; +}; + +export type RakPage = { + rows: Rak[]; + hasNext: boolean; +}; + +export type RakInput = { + namaRak: NonNullable; +}; + +export async function insertRak(input: RakInput): Promise { + const [res] = await amigoDb.insert(rak).values({ + namaRak: input.namaRak, + }); + + return res.insertId; +} + +export async function updateRak( + idRak: number, + input: RakInput, +): Promise { + const [res] = await amigoDb + .update(rak) + .set({ namaRak: input.namaRak }) + .where(eq(rak.idRak, idRak)); + + return res.affectedRows > 0; +} + +export async function deleteRak(idRak: number): Promise { + const [res] = await amigoDb.delete(rak).where(eq(rak.idRak, idRak)); + + return res.affectedRows > 0; +} + +export async function listRak(search?: string, page = 1): Promise { + const q = search?.trim(); + const pattern = q ? `%${escapeLike(q)}%` : ""; + + const rows = await amigoDb + .select({ + idRak: rak.idRak, + namaRak: rak.namaRak, + }) + .from(rak) + .where(q ? like(rak.namaRak, pattern) : undefined) + .orderBy(asc(rak.namaRak), asc(rak.idRak)) + .limit(PER_PAGE + 1) + .offset((page - 1) * PER_PAGE); + + return { + rows: rows.slice(0, PER_PAGE), + hasNext: rows.length > PER_PAGE, + }; +}