Unify master sheet form and move shared master components

- Move cari-master, master-sheet-form, and hapus-master-button into
  app/(dashboard)/master/_components since only master pages use them
- Merge MasterSheetForm and MasterMultiFieldSheetForm into one form that
  accepts multiple fields; forms needing select/textarea stay custom
- Drop the initial prop and hardcode { ok: false } as the action state seed
- Blur number inputs on wheel to prevent accidental value changes
- Update CLAUDE.md to describe the new layout and rules
main
Deardo Satria 22 hours ago
parent 5c89d5714f
commit 5abf8a7e0c

@ -66,11 +66,14 @@ app/
page.tsx Server Component: ambil data lewat data/, render page.tsx Server Component: ambil data lewat data/, render
actions.ts Server Actions fitur ini ('use server') actions.ts Server Actions fitur ini ('use server')
_components/ komponen khusus fitur ini _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) api/auth/[...all]/route.ts handler Better Auth (satu-satunya route handler yang wajar)
components/ components/
ui/ komponen shadcn (generated, jangan diedit manual kecuali perlu) ui/ komponen shadcn (generated, jangan diedit manual kecuali perlu)
<nama>.tsx komponen yang dipakai lintas fitur <nama>.tsx komponen yang dipakai lintas fitur
cari-master.tsx kotak pencarian master (prop basePath + placeholder)
pagination.tsx tombol halaman daftar (prop page, hasNext, hrefPage) pagination.tsx tombol halaman daftar (prop page, hasNext, hrefPage)
data/ Data Access Layer: fungsi baca/tulis DB, selalu 'server-only' data/ Data Access Layer: fungsi baca/tulis DB, selalu 'server-only'
db/ db/
@ -80,6 +83,7 @@ lib/
auth.ts config Better Auth (server) auth.ts config Better Auth (server)
auth-client.ts client Better Auth auth-client.ts client Better Auth
pagination.ts PER_PAGE, parse `?page=`, dan href halaman 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 utils.ts helper umum: cn (dibuat shadcn) dan escapeLike untuk LIKE
drizzle/ file migrasi Postgres (generated, ikut di-commit) drizzle/ file migrasi Postgres (generated, ikut di-commit)
proxy.ts pengganti middleware di Next 16; hanya cek ringan (redirect) 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 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. (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 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 - 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 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/`, (package `server-only` tidak di-install; Next.js menanganinya sendiri). Hanya `data/`, `db/`,

@ -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<DeleteState>;
};
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 (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
title={`Hapus ${entityLabel}`}
/>
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Hapus {nama || `${entityLabel} ${id}`}?
</AlertDialogTitle>
<AlertDialogDescription>
Data {entityLabel} {id} akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction}>
<input type="hidden" name={idName} value={id} />
{state.error && (
<p className="mb-4 text-sm text-destructive">{state.error}</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
<Button type="submit" variant="destructive" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Hapus
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
);
}

@ -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<F extends string = string> = {
name: F;
label: string;
type?: React.HTMLInputTypeAttribute;
required?: boolean;
};
type MasterSheetFormProps<F extends string> = {
action: (
prevState: FormState<F>,
formData: FormData,
) => Promise<FormState<F>>;
fields: readonly MasterField<F>[];
defaultValues?: Partial<Record<F, string | number | null>>;
hiddenFields?: Record<string, string | number>;
title: string;
description: string;
trigger: React.ReactElement;
};
export function MasterSheetForm<F extends string>({
action,
fields,
defaultValues,
hiddenFields,
title,
description,
trigger,
}: MasterSheetFormProps<F>) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: FormState<F>, formData: FormData) => {
const result = await action(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
{ ok: false },
);
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={trigger} />
<SheetContent>
<SheetHeader>
<SheetTitle>{title}</SheetTitle>
<SheetDescription>{description}</SheetDescription>
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{hiddenFields &&
Object.entries(hiddenFields).map(([name, value]) => (
<input key={name} type="hidden" name={name} value={value} />
))}
{fields.map((field) => {
const errors = state.fieldErrors?.[field.name];
return (
<Field key={field.name} data-invalid={!!errors}>
<FieldLabel htmlFor={field.name}>
{field.label}
{field.required && " *"}
</FieldLabel>
<Input
id={field.name}
name={field.name}
type={field.type}
onWheel={
field.type === "number"
? (e) => e.currentTarget.blur()
: undefined
}
defaultValue={
state.values?.[field.name] ??
defaultValues?.[field.name] ??
""
}
aria-invalid={!!errors}
required={field.required}
/>
<FieldError errors={errors?.map((message) => ({ message }))} />
</Field>
);
})}
{state.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<SheetFooter className="px-0">
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Simpan
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

@ -1,80 +1,22 @@
"use client"; import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
import { hapusPelanggan } from "../actions";
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";
type HapusPelangganButtonProps = { type HapusPelangganButtonProps = {
kdCustomer: string; kdCustomer: string;
nama: string; nama: string;
}; };
const initial: HapusPelangganState = { ok: false };
export function HapusPelangganButton({ export function HapusPelangganButton({
kdCustomer, kdCustomer,
nama, nama,
}: HapusPelangganButtonProps) { }: 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 ( return (
<AlertDialog open={open} onOpenChange={setOpen}> <HapusMasterButton
<AlertDialogTrigger id={kdCustomer}
render={ idName="kdCustomer"
<Button nama={nama}
variant="ghost" entityLabel="pelanggan"
size="icon-sm" action={hapusPelanggan}
className="text-destructive hover:bg-destructive/10"
title="Hapus pelanggan"
/> />
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus {nama}?</AlertDialogTitle>
<AlertDialogDescription>
Data pelanggan {kdCustomer} akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction}>
<input type="hidden" name="kdCustomer" value={kdCustomer} />
{state.error && (
<p className="mb-4 text-sm text-destructive">{state.error}</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
<Button type="submit" variant="destructive" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Hapus
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
); );
} }

@ -1,191 +1,49 @@
"use client"; "use client";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import { import {
Sheet, MasterSheetForm,
SheetContent, type MasterField,
SheetDescription, } from "@/app/(dashboard)/master/_components/master-sheet-form";
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";
import type { Pelanggan } from "@/data/pelanggan"; import type { Pelanggan } from "@/data/pelanggan";
import { simpanPelanggan, type SimpanPelangganState } from "../actions";
type PelangganFormProps = { type PelangganFormProps = {
pelanggan?: Pelanggan; pelanggan?: Pelanggan;
trigger: React.ReactElement; 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 (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={trigger} />
<SheetContent>
<SheetHeader>
<SheetTitle>
{pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"}
</SheetTitle>
<SheetDescription>
{pelanggan
? `Kode: ${pelanggan.kdCustomer}`
: "Isi detail pelanggan baru"}
</SheetDescription>
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{pelanggan && (
<input
type="hidden"
name="kdCustomer"
value={pelanggan.kdCustomer}
/>
)}
<FormInput
name="nama"
label="Nama *"
state={state}
defaultValue={pelanggan?.nama}
required
/>
<FormInput
name="alias"
label="Alias"
state={state}
defaultValue={pelanggan?.alias}
/>
<FormInput
name="ktp"
label="No. KTP"
state={state}
defaultValue={pelanggan?.ktp}
/>
<FormInput
name="alamat"
label="Alamat *"
state={state}
defaultValue={pelanggan?.alamat}
required
/>
<FormInput
name="wilayah"
label="Wilayah"
state={state}
defaultValue={pelanggan?.wilayah}
/>
<FormInput
name="telp"
label="Telepon"
state={state}
defaultValue={pelanggan?.telp}
/>
<FormInput
name="ponsel"
label="Ponsel"
state={state}
defaultValue={pelanggan?.ponsel}
/>
<FormInput
name="email"
label="Email"
type="email"
state={state}
defaultValue={pelanggan?.email}
/>
<FormInput
name="jenisCustomer"
label="Jenis Customer"
state={state}
defaultValue={pelanggan?.jenisCustomer}
/>
<FormInput
name="upline"
label="Upline"
state={state}
defaultValue={pelanggan?.upline}
/>
<FormInput
name="btsBon"
label="BTS Bon"
type="number"
state={state}
defaultValue={pelanggan?.btsBon}
/>
{state.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<SheetFooter className="px-0">
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Simpan
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
type FieldName = keyof NonNullable<SimpanPelangganState["values"]>; type FieldName = keyof NonNullable<SimpanPelangganState["values"]>;
type FormInputProps = Omit< const fields: MasterField<FieldName>[] = [
React.ComponentProps<"input">, { name: "nama", label: "Nama", required: true },
"name" | "defaultValue" { name: "alias", label: "Alias" },
> & { { name: "ktp", label: "No. KTP" },
name: FieldName; { name: "alamat", label: "Alamat", required: true },
label: string; { name: "wilayah", label: "Wilayah" },
state: SimpanPelangganState; { name: "telp", label: "Telepon" },
defaultValue?: string | number | null; { name: "ponsel", label: "Ponsel" },
}; { name: "email", label: "Email", type: "email" },
{ name: "jenisCustomer", label: "Jenis Customer" },
// Isian dari server (state.values) didahulukan supaya ketikan user tidak hilang { name: "upline", label: "Upline" },
// saat validasi gagal; kalau belum ada, pakai data pelanggan yang sedang diedit. { name: "btsBon", label: "BTS Bon", type: "number" },
function FormInput({ ];
name,
label,
state,
defaultValue,
...props
}: FormInputProps) {
const errors = state.fieldErrors?.[name];
export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) {
return ( return (
<Field data-invalid={!!errors}> <MasterSheetForm
<FieldLabel htmlFor={name}>{label}</FieldLabel> action={simpanPelanggan}
<Input fields={fields}
id={name} defaultValues={pelanggan}
name={name} hiddenFields={
defaultValue={state.values?.[name] ?? defaultValue ?? ""} pelanggan ? { kdCustomer: pelanggan.kdCustomer } : undefined
aria-invalid={!!errors} }
{...props} title={pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"}
description={
pelanggan
? `Kode: ${pelanggan.kdCustomer}`
: "Isi detail pelanggan baru"
}
trigger={trigger}
/> />
<FieldError errors={errors?.map((message) => ({ message }))} />
</Field>
); );
} }

@ -9,17 +9,18 @@ import {
updatePelanggan, updatePelanggan,
type PelangganInput, type PelangganInput,
} from "@/data/pelanggan"; } from "@/data/pelanggan";
import {
logActionError,
type DeleteState,
type FormState,
} from "@/lib/server-action";
type PelangganField = keyof typeof pelangganSchema.shape; type PelangganField = keyof typeof pelangganSchema.shape;
export type SimpanPelangganState = { export type SimpanPelangganState = FormState<PelangganField> & {
ok: boolean;
error?: string;
fieldErrors?: Partial<Record<PelangganField, string[]>>;
values?: Partial<Record<PelangganField, string>>;
kdCustomer?: string; kdCustomer?: string;
}; };
export type HapusPelangganState = { ok: boolean; error?: string }; export type HapusPelangganState = DeleteState;
const pelangganSchema = z.object({ const pelangganSchema = z.object({
nama: z.string().trim().min(1, "Nama wajib diisi").max(100), nama: z.string().trim().min(1, "Nama wajib diisi").max(100),
@ -84,8 +85,8 @@ export async function simpanPelanggan(
revalidatePath("/master/pelanggan"); revalidatePath("/master/pelanggan");
return { ok: true, kdCustomer }; return { ok: true, kdCustomer };
} catch (error) { } catch (error) {
console.error( logActionError(
"[simpanPelanggan] gagal", "simpanPelanggan",
{ userId: user.id, kdCustomer: kdCustomer || "(baru)", nama: input.nama }, { userId: user.id, kdCustomer: kdCustomer || "(baru)", nama: input.nama },
error, error,
); );
@ -116,11 +117,7 @@ export async function hapusPelanggan(
revalidatePath("/master/pelanggan"); revalidatePath("/master/pelanggan");
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
console.error( logActionError("hapusPelanggan", { userId: user.id, kdCustomer }, error);
"[hapusPelanggan] gagal",
{ userId: user.id, kdCustomer },
error,
);
return { return {
ok: false, ok: false,
error: "Gagal menghapus pelanggan, coba hapus ulang", error: "Gagal menghapus pelanggan, coba hapus ulang",

@ -1,6 +1,6 @@
import { Suspense } from "react"; 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 { DaftarPelanggan } from "./_components/daftar-pelanggan";
import { TambahPelangganButton } from "./_components/tambah-pelanggan-button"; import { TambahPelangganButton } from "./_components/tambah-pelanggan-button";

@ -1,77 +1,19 @@
"use client"; import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
import { hapusRak } from "../actions";
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 = { type HapusRakButtonProps = {
idRak: number; idRak: number;
nama: string; nama: string;
}; };
const initial: HapusRakState = { ok: false };
export function HapusRakButton({ idRak, nama }: HapusRakButtonProps) { 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 ( return (
<AlertDialog open={open} onOpenChange={setOpen}> <HapusMasterButton
<AlertDialogTrigger id={idRak}
render={ idName="idRak"
<Button nama={nama}
variant="ghost" entityLabel="rak"
size="icon-sm" action={hapusRak}
className="text-destructive hover:bg-destructive/10"
title="Hapus rak"
/> />
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus {nama || `rak ${idRak}`}?</AlertDialogTitle>
<AlertDialogDescription>
Data rak {idRak} akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction}>
<input type="hidden" name="idRak" value={idRak} />
{state.error && (
<p className="mb-4 text-sm text-destructive">{state.error}</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
<Button type="submit" variant="destructive" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Hapus
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
); );
} }

@ -1,84 +1,33 @@
"use client"; import type { ReactElement } from "react";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import { import {
Sheet, MasterSheetForm,
SheetContent, type MasterField,
SheetDescription, } from "@/app/(dashboard)/master/_components/master-sheet-form";
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 type { Rak } from "@/data/rak";
import { simpanRak, type SimpanRakState } from "../actions"; import { simpanRak, type SimpanRakState } from "../actions";
type RakFormProps = { type RakFormProps = {
rak?: Rak; rak?: Rak;
trigger: React.ReactElement; trigger: ReactElement;
}; };
const initial: SimpanRakState = { ok: false }; type FieldName = keyof NonNullable<SimpanRakState["values"]>;
export function RakForm({ rak, trigger }: RakFormProps) { const fields: MasterField<FieldName>[] = [
const [open, setOpen] = useState(false); { name: "namaRak", label: "Nama Rak", required: true },
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;
export function RakForm({ rak, trigger }: RakFormProps) {
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <MasterSheetForm
<SheetTrigger render={trigger} /> action={simpanRak}
<SheetContent> fields={fields}
<SheetHeader> defaultValues={rak && { namaRak: rak.namaRak }}
<SheetTitle>{rak ? `Edit ${rak.namaRak}` : "Tambah Rak"}</SheetTitle> hiddenFields={rak && { idRak: rak.idRak }}
<SheetDescription> title={rak ? `Edit ${rak.namaRak}` : "Tambah Rak"}
{rak ? `ID: ${rak.idRak}` : "Isi nama rak baru"} description={rak ? `ID: ${rak.idRak}` : "Isi nama rak baru"}
</SheetDescription> trigger={trigger}
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{rak && <input type="hidden" name="idRak" value={rak.idRak} />}
<Field data-invalid={!!errors}>
<FieldLabel htmlFor="namaRak">Nama Rak *</FieldLabel>
<Input
id="namaRak"
name="namaRak"
defaultValue={state.values?.namaRak ?? rak?.namaRak ?? ""}
aria-invalid={!!errors}
required
/> />
<FieldError errors={errors?.map((message) => ({ message }))} />
</Field>
{state.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<SheetFooter className="px-0">
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Simpan
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
); );
} }

@ -4,16 +4,14 @@ import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { getCurrentUser } from "@/data/auth"; import { getCurrentUser } from "@/data/auth";
import { deleteRak, insertRak, updateRak, type RakInput } from "@/data/rak"; import { deleteRak, insertRak, updateRak, type RakInput } from "@/data/rak";
import {
logActionError,
type DeleteState,
type FormState,
} from "@/lib/server-action";
export type SimpanRakState = { export type SimpanRakState = FormState<"namaRak">;
ok: boolean; export type HapusRakState = DeleteState;
error?: string;
fieldErrors?: { namaRak?: string[] };
values?: { namaRak?: string };
idRak?: number;
};
export type HapusRakState = { ok: boolean; error?: string };
const rakSchema = z.object({ const rakSchema = z.object({
namaRak: z namaRak: z
@ -55,15 +53,15 @@ export async function simpanRak(
return { ok: false, error: "Rak tidak ditemukan", values }; return { ok: false, error: "Rak tidak ditemukan", values };
} }
revalidatePath("/master/rak"); revalidatePath("/master/rak");
return { ok: true, idRak: id }; return { ok: true };
} }
const idBaru = await insertRak(input); await insertRak(input);
revalidatePath("/master/rak"); revalidatePath("/master/rak");
return { ok: true, idRak: idBaru }; return { ok: true };
} catch (error) { } catch (error) {
console.error( logActionError(
"[simpanRak] gagal", "simpanRak",
{ {
userId: user.id, userId: user.id,
idRak: id ?? "(baru)", idRak: id ?? "(baru)",
@ -98,7 +96,7 @@ export async function hapusRak(
revalidatePath("/master/rak"); revalidatePath("/master/rak");
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
console.error("[hapusRak] gagal", { userId: user.id, id }, error); logActionError("hapusRak", { userId: user.id, id }, error);
return { return {
ok: false, ok: false,
error: "Gagal menghapus rak, coba hapus ulang", error: "Gagal menghapus rak, coba hapus ulang",

@ -2,7 +2,7 @@ import { Suspense } from "react";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { DaftarRak } from "./_components/daftar-rak";
import { RakForm } from "./_components/rak-form"; import { RakForm } from "./_components/rak-form";

@ -1,82 +1,22 @@
"use client"; import { HapusMasterButton } from "@/app/(dashboard)/master/_components/hapus-master-button";
import { hapusSatuanBarang } from "../actions";
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 = { type HapusSatuanBarangButtonProps = {
idSatuanBarang: number; idSatuanBarang: number;
nama: string; nama: string;
}; };
const initial: HapusSatuanBarangState = { ok: false };
export function HapusSatuanBarangButton({ export function HapusSatuanBarangButton({
idSatuanBarang, idSatuanBarang,
nama, nama,
}: HapusSatuanBarangButtonProps) { }: 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 ( return (
<AlertDialog open={open} onOpenChange={setOpen}> <HapusMasterButton
<AlertDialogTrigger id={idSatuanBarang}
render={ idName="idSatuanBarang"
<Button nama={nama}
variant="ghost" entityLabel="satuan"
size="icon-sm" action={hapusSatuanBarang}
className="text-destructive hover:bg-destructive/10"
title="Hapus satuan"
/> />
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Hapus {nama || `satuan ${idSatuanBarang}`}?
</AlertDialogTitle>
<AlertDialogDescription>
Data satuan {idSatuanBarang} akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction}>
<input type="hidden" name="idSatuanBarang" value={idSatuanBarang} />
{state.error && (
<p className="mb-4 text-sm text-destructive">{state.error}</p>
)}
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
<Button type="submit" variant="destructive" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Hapus
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
); );
} }

@ -1,96 +1,35 @@
"use client"; import type { ReactElement } from "react";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import { import {
Sheet, MasterSheetForm,
SheetContent, type MasterField,
SheetDescription, } from "@/app/(dashboard)/master/_components/master-sheet-form";
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 type { SatuanBarang } from "@/data/satuan-barang";
import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions"; import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions";
type SatuanBarangFormProps = { type SatuanBarangFormProps = {
satuan?: SatuanBarang; satuan?: SatuanBarang;
trigger: React.ReactElement; trigger: ReactElement;
}; };
const initial: SimpanSatuanBarangState = { ok: false }; type FieldName = keyof NonNullable<SimpanSatuanBarangState["values"]>;
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) { const fields: MasterField<FieldName>[] = [
const [open, setOpen] = useState(false); { name: "namaSatuanBarang", label: "Nama Satuan", required: true },
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;
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) {
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <MasterSheetForm
<SheetTrigger render={trigger} /> action={simpanSatuanBarang}
<SheetContent> fields={fields}
<SheetHeader> defaultValues={satuan && { namaSatuanBarang: satuan.namaSatuanBarang }}
<SheetTitle> hiddenFields={satuan && { idSatuanBarang: satuan.idSatuanBarang }}
{satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"} title={satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
</SheetTitle> description={
<SheetDescription> satuan ? `ID: ${satuan.idSatuanBarang}` : "Isi nama satuan barang baru"
{satuan
? `ID: ${satuan.idSatuanBarang}`
: "Isi nama satuan barang baru"}
</SheetDescription>
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{satuan && (
<input
type="hidden"
name="idSatuanBarang"
value={satuan.idSatuanBarang}
/>
)}
<Field data-invalid={!!errors}>
<FieldLabel htmlFor="namaSatuanBarang">Nama Satuan *</FieldLabel>
<Input
id="namaSatuanBarang"
name="namaSatuanBarang"
defaultValue={
state.values?.namaSatuanBarang ?? satuan?.namaSatuanBarang ?? ""
} }
aria-invalid={!!errors} trigger={trigger}
required
/> />
<FieldError errors={errors?.map((message) => ({ message }))} />
</Field>
{state.error && (
<p className="text-sm text-destructive">{state.error}</p>
)}
<SheetFooter className="px-0">
<Button type="submit" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Simpan
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
); );
} }

@ -10,16 +10,14 @@ import {
updateSatuanBarang, updateSatuanBarang,
type SatuanBarangInput, type SatuanBarangInput,
} from "@/data/satuan-barang"; } from "@/data/satuan-barang";
import {
logActionError,
type DeleteState,
type FormState,
} from "@/lib/server-action";
export type SimpanSatuanBarangState = { export type SimpanSatuanBarangState = FormState<"namaSatuanBarang">;
ok: boolean; export type HapusSatuanBarangState = DeleteState;
error?: string;
fieldErrors?: { namaSatuanBarang?: string[] };
values?: { namaSatuanBarang?: string };
idSatuanBarang?: number;
};
export type HapusSatuanBarangState = { ok: boolean; error?: string };
const satuanBarangSchema = z.object({ const satuanBarangSchema = z.object({
namaSatuanBarang: z namaSatuanBarang: z
@ -61,15 +59,15 @@ export async function simpanSatuanBarang(
return { ok: false, error: "Satuan tidak ditemukan", values }; return { ok: false, error: "Satuan tidak ditemukan", values };
} }
revalidatePath("/master/satuan-barang"); revalidatePath("/master/satuan-barang");
return { ok: true, idSatuanBarang: id }; return { ok: true };
} }
const idBaru = await insertSatuanBarang(input); await insertSatuanBarang(input);
revalidatePath("/master/satuan-barang"); revalidatePath("/master/satuan-barang");
return { ok: true, idSatuanBarang: idBaru }; return { ok: true };
} catch (error) { } catch (error) {
console.error( logActionError(
"[simpanSatuanBarang] gagal", "simpanSatuanBarang",
{ {
userId: user.id, userId: user.id,
idSatuanBarang: id ?? "(baru)", idSatuanBarang: id ?? "(baru)",
@ -112,7 +110,7 @@ export async function hapusSatuanBarang(
revalidatePath("/master/satuan-barang"); revalidatePath("/master/satuan-barang");
return { ok: true }; return { ok: true };
} catch (error) { } catch (error) {
console.error("[hapusSatuanBarang] gagal", { userId: user.id, id }, error); logActionError("hapusSatuanBarang", { userId: user.id, id }, error);
return { return {
ok: false, ok: false,
error: "Gagal menghapus satuan, coba hapus ulang", error: "Gagal menghapus satuan, coba hapus ulang",

@ -2,7 +2,7 @@ import { Suspense } from "react";
import { PlusIcon } from "lucide-react"; import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { DaftarSatuanBarang } from "./_components/daftar-satuan-barang";
import { SatuanBarangForm } from "./_components/satuan-barang-form"; import { SatuanBarangForm } from "./_components/satuan-barang-form";

@ -0,0 +1,16 @@
export type FormState<F extends string = string> = {
ok: boolean;
error?: string;
fieldErrors?: Partial<Record<F, string[]>>;
values?: Partial<Record<F, string>>;
};
export type DeleteState = { ok: boolean; error?: string };
export function logActionError(
action: string,
context: Record<string, unknown>,
error: unknown,
): void {
console.error(`[${action}] gagal`, context, error);
}
Loading…
Cancel
Save