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
parent
5c89d5714f
commit
5abf8a7e0c
@ -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 { 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 (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
title="Hapus pelanggan"
|
||||
<HapusMasterButton
|
||||
id={kdCustomer}
|
||||
idName="kdCustomer"
|
||||
nama={nama}
|
||||
entityLabel="pelanggan"
|
||||
action={hapusPelanggan}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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";
|
||||
|
||||
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 };
|
||||
type FieldName = keyof NonNullable<SimpanPelangganState["values"]>;
|
||||
|
||||
const fields: MasterField<FieldName>[] = [
|
||||
{ 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) {
|
||||
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
|
||||
<MasterSheetForm
|
||||
action={simpanPelanggan}
|
||||
fields={fields}
|
||||
defaultValues={pelanggan}
|
||||
hiddenFields={
|
||||
pelanggan ? { kdCustomer: pelanggan.kdCustomer } : undefined
|
||||
}
|
||||
title={pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"}
|
||||
description={
|
||||
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>
|
||||
);
|
||||
: "Isi detail pelanggan baru"
|
||||
}
|
||||
|
||||
type FieldName = keyof NonNullable<SimpanPelangganState["values"]>;
|
||||
|
||||
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];
|
||||
|
||||
return (
|
||||
<Field data-invalid={!!errors}>
|
||||
<FieldLabel htmlFor={name}>{label}</FieldLabel>
|
||||
<Input
|
||||
id={name}
|
||||
name={name}
|
||||
defaultValue={state.values?.[name] ?? defaultValue ?? ""}
|
||||
aria-invalid={!!errors}
|
||||
{...props}
|
||||
trigger={trigger}
|
||||
/>
|
||||
<FieldError errors={errors?.map((message) => ({ message }))} />
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
title="Hapus rak"
|
||||
<HapusMasterButton
|
||||
id={idRak}
|
||||
idName="idRak"
|
||||
nama={nama}
|
||||
entityLabel="rak"
|
||||
action={hapusRak}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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 { 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<SimpanRakState["values"]>;
|
||||
|
||||
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<FieldName>[] = [
|
||||
{ name: "namaRak", label: "Nama Rak", required: true },
|
||||
];
|
||||
|
||||
export function RakForm({ rak, trigger }: RakFormProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger render={trigger} />
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>{rak ? `Edit ${rak.namaRak}` : "Tambah Rak"}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{rak ? `ID: ${rak.idRak}` : "Isi nama rak baru"}
|
||||
</SheetDescription>
|
||||
</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
|
||||
<MasterSheetForm
|
||||
action={simpanRak}
|
||||
fields={fields}
|
||||
defaultValues={rak && { namaRak: rak.namaRak }}
|
||||
hiddenFields={rak && { idRak: rak.idRak }}
|
||||
title={rak ? `Edit ${rak.namaRak}` : "Tambah Rak"}
|
||||
description={rak ? `ID: ${rak.idRak}` : "Isi nama rak baru"}
|
||||
trigger={trigger}
|
||||
/>
|
||||
<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,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 (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-destructive hover:bg-destructive/10"
|
||||
title="Hapus satuan"
|
||||
<HapusMasterButton
|
||||
id={idSatuanBarang}
|
||||
idName="idSatuanBarang"
|
||||
nama={nama}
|
||||
entityLabel="satuan"
|
||||
action={hapusSatuanBarang}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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 { 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<SimpanSatuanBarangState["values"]>;
|
||||
|
||||
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<FieldName>[] = [
|
||||
{ name: "namaSatuanBarang", label: "Nama Satuan", required: true },
|
||||
];
|
||||
|
||||
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger render={trigger} />
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
{satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{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 ?? ""
|
||||
<MasterSheetForm
|
||||
action={simpanSatuanBarang}
|
||||
fields={fields}
|
||||
defaultValues={satuan && { namaSatuanBarang: satuan.namaSatuanBarang }}
|
||||
hiddenFields={satuan && { idSatuanBarang: satuan.idSatuanBarang }}
|
||||
title={satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
|
||||
description={
|
||||
satuan ? `ID: ${satuan.idSatuanBarang}` : "Isi nama satuan barang baru"
|
||||
}
|
||||
aria-invalid={!!errors}
|
||||
required
|
||||
trigger={trigger}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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…
Reference in New Issue