diff --git a/CLAUDE.md b/CLAUDE.md index c38feea..afe6807 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,11 +66,14 @@ app/ page.tsx Server Component: ambil data lewat data/, render actions.ts Server Actions fitur ini ('use server') _components/ komponen khusus fitur ini + (dashboard)/master/_components/ komponen bersama semua halaman master + cari-master.tsx kotak pencarian master (prop basePath + placeholder) + master-sheet-form.tsx form sheet master, input teks saja (action, fields, defaultValues) + hapus-master-button.tsx dialog konfirmasi hapus master (id, idName, entityLabel) api/auth/[...all]/route.ts handler Better Auth (satu-satunya route handler yang wajar) components/ ui/ komponen shadcn (generated, jangan diedit manual kecuali perlu) .tsx komponen yang dipakai lintas fitur - cari-master.tsx kotak pencarian master (prop basePath + placeholder) pagination.tsx tombol halaman daftar (prop page, hasNext, hrefPage) data/ Data Access Layer: fungsi baca/tulis DB, selalu 'server-only' db/ @@ -80,6 +83,7 @@ lib/ auth.ts config Better Auth (server) auth-client.ts client Better Auth pagination.ts PER_PAGE, parse `?page=`, dan href halaman + server-action.ts tipe state action (FormState/DeleteState) dan logActionError utils.ts helper umum: cn (dibuat shadcn) dan escapeLike untuk LIKE drizzle/ file migrasi Postgres (generated, ikut di-commit) proxy.ts pengganti middleware di Next 16; hanya cek ringan (redirect) @@ -113,6 +117,10 @@ proxy.ts pengganti middleware di Next 16; hanya cek rin Ke UI hanya dikirim pesan bahasa Indonesia yang jelas dan bisa ditindaklanjuti user (mis. "Kode pelanggan sudah dipakai, coba simpan ulang"), tidak pernah pesan error DB mentah. - Form di client memakai `useActionState` untuk menampilkan error/pending dari action. +- Form master yang isinya input teks saja memakai `MasterSheetForm`, dan dialog hapus memakai + `HapusMasterButton`; tipe state action dan log error dari `lib/server-action.ts`. Form yang + butuh select, textarea, atau lookup ditulis sendiri di `_components/` fiturnya; jangan + menambah opsi ke config `MasterSheetForm`. - File di `data/` diawali `import "server-only"` supaya tidak bisa ter-import ke client component. `db/` dan `lib/auth.ts` sengaja tanpa itu karena dibaca CLI `auth` di luar Next.js (package `server-only` tidak di-install; Next.js menanganinya sendiri). Hanya `data/`, `db/`, diff --git a/components/cari-master.tsx b/app/(dashboard)/master/_components/cari-master.tsx similarity index 100% rename from components/cari-master.tsx rename to app/(dashboard)/master/_components/cari-master.tsx diff --git a/app/(dashboard)/master/_components/hapus-master-button.tsx b/app/(dashboard)/master/_components/hapus-master-button.tsx new file mode 100644 index 0000000..ef3c948 --- /dev/null +++ b/app/(dashboard)/master/_components/hapus-master-button.tsx @@ -0,0 +1,86 @@ +"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 type { DeleteState } from "@/lib/server-action"; + +type HapusMasterButtonProps = { + id: number | string; + idName: string; + nama: string; + entityLabel: string; + action: (prevState: DeleteState, formData: FormData) => Promise; +}; + +export function HapusMasterButton({ + id, + idName, + nama, + entityLabel, + action, +}: HapusMasterButtonProps) { + const [open, setOpen] = useState(false); + const [state, formAction, pending] = useActionState( + async (prevState: DeleteState, formData: FormData) => { + const result = await action(prevState, formData); + if (result.ok) setOpen(false); + return result; + }, + { ok: false }, + ); + + return ( + + + } + > + + Hapus + + + + + + Hapus {nama || `${entityLabel} ${id}`}? + + + Data {entityLabel} {id} akan dihapus permanen. + + + +
+ + {state.error && ( +

{state.error}

+ )} + + Batal + + +
+
+
+ ); +} diff --git a/app/(dashboard)/master/_components/master-sheet-form.tsx b/app/(dashboard)/master/_components/master-sheet-form.tsx new file mode 100644 index 0000000..b907de8 --- /dev/null +++ b/app/(dashboard)/master/_components/master-sheet-form.tsx @@ -0,0 +1,124 @@ +"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 { FormState } from "@/lib/server-action"; + +// Hanya untuk input teks biasa. Form yang butuh select, textarea, atau lookup ditulis sendiri +// di _components fitur itu, jangan menambah opsi ke config ini. +export type MasterField = { + name: F; + label: string; + type?: React.HTMLInputTypeAttribute; + required?: boolean; +}; + +type MasterSheetFormProps = { + action: ( + prevState: FormState, + formData: FormData, + ) => Promise>; + fields: readonly MasterField[]; + defaultValues?: Partial>; + hiddenFields?: Record; + title: string; + description: string; + trigger: React.ReactElement; +}; + +export function MasterSheetForm({ + action, + fields, + defaultValues, + hiddenFields, + title, + description, + trigger, +}: MasterSheetFormProps) { + const [open, setOpen] = useState(false); + const [state, formAction, pending] = useActionState( + async (prevState: FormState, formData: FormData) => { + const result = await action(prevState, formData); + if (result.ok) setOpen(false); + return result; + }, + { ok: false }, + ); + + return ( + + + + + {title} + {description} + + +
+ {hiddenFields && + Object.entries(hiddenFields).map(([name, value]) => ( + + ))} + + {fields.map((field) => { + const errors = state.fieldErrors?.[field.name]; + + return ( + + + {field.label} + {field.required && " *"} + + e.currentTarget.blur() + : undefined + } + defaultValue={ + state.values?.[field.name] ?? + defaultValues?.[field.name] ?? + "" + } + aria-invalid={!!errors} + required={field.required} + /> + ({ message }))} /> + + ); + })} + + {state.error && ( +

{state.error}

+ )} + + + + +
+
+
+ ); +} diff --git a/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx b/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx index b611980..f383e43 100644 --- a/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx +++ b/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx @@ -1,80 +1,22 @@ -"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 { hapusPelanggan, type HapusPelangganState } from "../actions"; +import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button"; +import { hapusPelanggan } from "../actions"; type HapusPelangganButtonProps = { kdCustomer: string; nama: string; }; -const initial: HapusPelangganState = { ok: false }; - export function HapusPelangganButton({ kdCustomer, nama, }: HapusPelangganButtonProps) { - const [open, setOpen] = useState(false); - const [state, formAction, pending] = useActionState( - async (prevState: HapusPelangganState, formData: FormData) => { - const result = await hapusPelanggan(prevState, formData); - if (result.ok) setOpen(false); - return result; - }, - initial, - ); - return ( - - - } - > - - Hapus - - - - - Hapus {nama}? - - Data pelanggan {kdCustomer} akan dihapus permanen. - - - -
- - {state.error && ( -

{state.error}

- )} - - Batal - - -
-
-
+ ); } diff --git a/app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx b/app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx index 2e26d82..173bcdc 100644 --- a/app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx +++ b/app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx @@ -1,191 +1,49 @@ "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 { simpanPelanggan, type SimpanPelangganState } from "../actions"; + MasterSheetForm, + type MasterField, +} from "@/app/(dashboard)/master/_components/master-sheet-form"; import type { Pelanggan } from "@/data/pelanggan"; +import { simpanPelanggan, type SimpanPelangganState } from "../actions"; type PelangganFormProps = { pelanggan?: Pelanggan; trigger: React.ReactElement; }; -const initial: SimpanPelangganState = { ok: false }; - -export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) { - const [open, setOpen] = useState(false); - // Sheet ditutup di sini, bukan di useEffect: effect dengan deps [state.ok] tidak - // jalan lagi saat simpan sukses kedua (true -> true), jadi sheet tetap terbuka. - const [state, formAction, pending] = useActionState( - async (prevState: SimpanPelangganState, formData: FormData) => { - const result = await simpanPelanggan(prevState, formData); - if (result.ok) setOpen(false); - return result; - }, - initial, - ); - - return ( - - - - - - {pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"} - - - {pelanggan - ? `Kode: ${pelanggan.kdCustomer}` - : "Isi detail pelanggan baru"} - - - -
- {pelanggan && ( - - )} - - - - - - - - - - - - - - {state.error && ( -

{state.error}

- )} - - - - - -
-
- ); -} - type FieldName = keyof NonNullable; -type FormInputProps = Omit< - React.ComponentProps<"input">, - "name" | "defaultValue" -> & { - name: FieldName; - label: string; - state: SimpanPelangganState; - defaultValue?: string | number | null; -}; - -// Isian dari server (state.values) didahulukan supaya ketikan user tidak hilang -// saat validasi gagal; kalau belum ada, pakai data pelanggan yang sedang diedit. -function FormInput({ - name, - label, - state, - defaultValue, - ...props -}: FormInputProps) { - const errors = state.fieldErrors?.[name]; +const fields: MasterField[] = [ + { name: "nama", label: "Nama", required: true }, + { name: "alias", label: "Alias" }, + { name: "ktp", label: "No. KTP" }, + { name: "alamat", label: "Alamat", required: true }, + { name: "wilayah", label: "Wilayah" }, + { name: "telp", label: "Telepon" }, + { name: "ponsel", label: "Ponsel" }, + { name: "email", label: "Email", type: "email" }, + { name: "jenisCustomer", label: "Jenis Customer" }, + { name: "upline", label: "Upline" }, + { name: "btsBon", label: "BTS Bon", type: "number" }, +]; +export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) { return ( - - {label} - - ({ message }))} /> - + ); } diff --git a/app/(dashboard)/master/pelanggan/actions.ts b/app/(dashboard)/master/pelanggan/actions.ts index ae111b5..82dcf5b 100644 --- a/app/(dashboard)/master/pelanggan/actions.ts +++ b/app/(dashboard)/master/pelanggan/actions.ts @@ -9,17 +9,18 @@ import { updatePelanggan, type PelangganInput, } from "@/data/pelanggan"; +import { + logActionError, + type DeleteState, + type FormState, +} from "@/lib/server-action"; type PelangganField = keyof typeof pelangganSchema.shape; -export type SimpanPelangganState = { - ok: boolean; - error?: string; - fieldErrors?: Partial>; - values?: Partial>; +export type SimpanPelangganState = FormState & { kdCustomer?: string; }; -export type HapusPelangganState = { ok: boolean; error?: string }; +export type HapusPelangganState = DeleteState; const pelangganSchema = z.object({ nama: z.string().trim().min(1, "Nama wajib diisi").max(100), @@ -84,8 +85,8 @@ export async function simpanPelanggan( revalidatePath("/master/pelanggan"); return { ok: true, kdCustomer }; } catch (error) { - console.error( - "[simpanPelanggan] gagal", + logActionError( + "simpanPelanggan", { userId: user.id, kdCustomer: kdCustomer || "(baru)", nama: input.nama }, error, ); @@ -116,11 +117,7 @@ export async function hapusPelanggan( revalidatePath("/master/pelanggan"); return { ok: true }; } catch (error) { - console.error( - "[hapusPelanggan] gagal", - { userId: user.id, kdCustomer }, - error, - ); + logActionError("hapusPelanggan", { userId: user.id, kdCustomer }, error); return { ok: false, error: "Gagal menghapus pelanggan, coba hapus ulang", diff --git a/app/(dashboard)/master/pelanggan/page.tsx b/app/(dashboard)/master/pelanggan/page.tsx index 8542107..8687d53 100644 --- a/app/(dashboard)/master/pelanggan/page.tsx +++ b/app/(dashboard)/master/pelanggan/page.tsx @@ -1,6 +1,6 @@ import { Suspense } from "react"; -import { CariMaster } from "@/components/cari-master"; +import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master"; import { DaftarPelanggan } from "./_components/daftar-pelanggan"; import { TambahPelangganButton } from "./_components/tambah-pelanggan-button"; diff --git a/app/(dashboard)/master/rak/_components/hapus-rak-button.tsx b/app/(dashboard)/master/rak/_components/hapus-rak-button.tsx index dba9f29..7d4c9c7 100644 --- a/app/(dashboard)/master/rak/_components/hapus-rak-button.tsx +++ b/app/(dashboard)/master/rak/_components/hapus-rak-button.tsx @@ -1,77 +1,19 @@ -"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"; +import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button"; +import { hapusRak } 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 index 0c4c6b2..20ae6f9 100644 --- a/app/(dashboard)/master/rak/_components/rak-form.tsx +++ b/app/(dashboard)/master/rak/_components/rak-form.tsx @@ -1,84 +1,33 @@ -"use client"; - -import { useActionState, useState } from "react"; -import { Loader2Icon } from "lucide-react"; +import type { ReactElement } from "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"; + MasterSheetForm, + type MasterField, +} from "@/app/(dashboard)/master/_components/master-sheet-form"; import type { Rak } from "@/data/rak"; import { simpanRak, type SimpanRakState } from "../actions"; type RakFormProps = { rak?: Rak; - trigger: React.ReactElement; + trigger: ReactElement; }; -const initial: SimpanRakState = { ok: false }; +type FieldName = keyof NonNullable; -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; +const fields: MasterField[] = [ + { name: "namaRak", label: "Nama Rak", required: true }, +]; +export function RakForm({ rak, trigger }: RakFormProps) { 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 index dadcf92..21c9e77 100644 --- a/app/(dashboard)/master/rak/actions.ts +++ b/app/(dashboard)/master/rak/actions.ts @@ -4,16 +4,14 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; import { getCurrentUser } from "@/data/auth"; import { deleteRak, insertRak, updateRak, type RakInput } from "@/data/rak"; +import { + logActionError, + type DeleteState, + type FormState, +} from "@/lib/server-action"; -export type SimpanRakState = { - ok: boolean; - error?: string; - fieldErrors?: { namaRak?: string[] }; - values?: { namaRak?: string }; - idRak?: number; -}; - -export type HapusRakState = { ok: boolean; error?: string }; +export type SimpanRakState = FormState<"namaRak">; +export type HapusRakState = DeleteState; const rakSchema = z.object({ namaRak: z @@ -55,15 +53,15 @@ export async function simpanRak( return { ok: false, error: "Rak tidak ditemukan", values }; } revalidatePath("/master/rak"); - return { ok: true, idRak: id }; + return { ok: true }; } - const idBaru = await insertRak(input); + await insertRak(input); revalidatePath("/master/rak"); - return { ok: true, idRak: idBaru }; + return { ok: true }; } catch (error) { - console.error( - "[simpanRak] gagal", + logActionError( + "simpanRak", { userId: user.id, idRak: id ?? "(baru)", @@ -98,7 +96,7 @@ export async function hapusRak( revalidatePath("/master/rak"); return { ok: true }; } catch (error) { - console.error("[hapusRak] gagal", { userId: user.id, id }, error); + logActionError("hapusRak", { 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 index fc91448..714be1b 100644 --- a/app/(dashboard)/master/rak/page.tsx +++ b/app/(dashboard)/master/rak/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import { PlusIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { CariMaster } from "@/components/cari-master"; +import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master"; import { DaftarRak } from "./_components/daftar-rak"; import { RakForm } from "./_components/rak-form"; 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 index 417fb87..4460494 100644 --- a/app/(dashboard)/master/satuan-barang/_components/hapus-satuan-barang-button.tsx +++ b/app/(dashboard)/master/satuan-barang/_components/hapus-satuan-barang-button.tsx @@ -1,82 +1,22 @@ -"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"; +import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button"; +import { hapusSatuanBarang } 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 index 945883b..0a25308 100644 --- a/app/(dashboard)/master/satuan-barang/_components/satuan-barang-form.tsx +++ b/app/(dashboard)/master/satuan-barang/_components/satuan-barang-form.tsx @@ -1,96 +1,35 @@ -"use client"; - -import { useActionState, useState } from "react"; -import { Loader2Icon } from "lucide-react"; +import type { ReactElement } from "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"; + MasterSheetForm, + type MasterField, +} from "@/app/(dashboard)/master/_components/master-sheet-form"; import type { SatuanBarang } from "@/data/satuan-barang"; import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions"; type SatuanBarangFormProps = { satuan?: SatuanBarang; - trigger: React.ReactElement; + trigger: ReactElement; }; -const initial: SimpanSatuanBarangState = { ok: false }; +type FieldName = keyof NonNullable; -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; +const fields: MasterField[] = [ + { name: "namaSatuanBarang", label: "Nama Satuan", required: true }, +]; +export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) { 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 index 839fe83..67edbe7 100644 --- a/app/(dashboard)/master/satuan-barang/actions.ts +++ b/app/(dashboard)/master/satuan-barang/actions.ts @@ -10,16 +10,14 @@ import { updateSatuanBarang, type SatuanBarangInput, } from "@/data/satuan-barang"; +import { + logActionError, + type DeleteState, + type FormState, +} from "@/lib/server-action"; -export type SimpanSatuanBarangState = { - ok: boolean; - error?: string; - fieldErrors?: { namaSatuanBarang?: string[] }; - values?: { namaSatuanBarang?: string }; - idSatuanBarang?: number; -}; - -export type HapusSatuanBarangState = { ok: boolean; error?: string }; +export type SimpanSatuanBarangState = FormState<"namaSatuanBarang">; +export type HapusSatuanBarangState = DeleteState; const satuanBarangSchema = z.object({ namaSatuanBarang: z @@ -61,15 +59,15 @@ export async function simpanSatuanBarang( return { ok: false, error: "Satuan tidak ditemukan", values }; } revalidatePath("/master/satuan-barang"); - return { ok: true, idSatuanBarang: id }; + return { ok: true }; } - const idBaru = await insertSatuanBarang(input); + await insertSatuanBarang(input); revalidatePath("/master/satuan-barang"); - return { ok: true, idSatuanBarang: idBaru }; + return { ok: true }; } catch (error) { - console.error( - "[simpanSatuanBarang] gagal", + logActionError( + "simpanSatuanBarang", { userId: user.id, idSatuanBarang: id ?? "(baru)", @@ -112,7 +110,7 @@ export async function hapusSatuanBarang( revalidatePath("/master/satuan-barang"); return { ok: true }; } catch (error) { - console.error("[hapusSatuanBarang] gagal", { userId: user.id, id }, error); + logActionError("hapusSatuanBarang", { 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 index bcbf392..b83a73e 100644 --- a/app/(dashboard)/master/satuan-barang/page.tsx +++ b/app/(dashboard)/master/satuan-barang/page.tsx @@ -2,7 +2,7 @@ import { Suspense } from "react"; import { PlusIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { CariMaster } from "@/components/cari-master"; +import { CariMaster } from "@/app/(dashboard)/master/_components/cari-master"; import { DaftarSatuanBarang } from "./_components/daftar-satuan-barang"; import { SatuanBarangForm } from "./_components/satuan-barang-form"; diff --git a/lib/server-action.ts b/lib/server-action.ts new file mode 100644 index 0000000..e20c104 --- /dev/null +++ b/lib/server-action.ts @@ -0,0 +1,16 @@ +export type FormState = { + ok: boolean; + error?: string; + fieldErrors?: Partial>; + values?: Partial>; +}; + +export type DeleteState = { ok: boolean; error?: string }; + +export function logActionError( + action: string, + context: Record, + error: unknown, +): void { + console.error(`[${action}] gagal`, context, error); +}