Fix customer form state, delete dialog, and action conventions

main
Deardo Satria 2 days ago
parent 3bc5ce6d70
commit e1c6104004

@ -19,6 +19,9 @@ export const navItems: NavItem[] = [
{
title: "Master Data",
icon: DatabaseIcon,
items: [{ title: "Satuan Barang", href: "/master/satuan-barang" }, { title: "Data Pelanggan", href: "/master/pelanggan" }],
items: [
{ title: "Satuan Barang", href: "/master/satuan-barang" },
{ title: "Data Pelanggan", href: "/master/pelanggan" },
],
},
];

@ -1,18 +1,20 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { useActionState, useState } from "react";
import { Loader2Icon, Trash2Icon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { hapusPelanggan, type HapusPelangganState } from "../action";
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { hapusPelanggan, type HapusPelangganState } from "../actions";
type HapusPelangganButtonProps = {
kdCustomer: string;
@ -26,57 +28,53 @@ export function HapusPelangganButton({
nama,
}: HapusPelangganButtonProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(hapusPelanggan, initial);
useEffect(() => {
if (state.ok) setOpen(false);
}, [state.ok]);
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 (
<Sheet open={open} onOpenChange={setOpen}>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setOpen(true)}
className="text-destructive hover:bg-destructive/10"
title="Hapus pelanggan"
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger
render={
<Button
variant="ghost"
size="icon-sm"
className="text-destructive hover:bg-destructive/10"
title="Hapus pelanggan"
/>
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</Button>
</AlertDialogTrigger>
<SheetContent
side="top"
className="data-[side=top]:inset-x-4 data-[side=top]:top-24 data-[side=top]:mx-auto data-[side=top]:w-full data-[side=top]:max-w-sm data-[side=top]:rounded-lg data-[side=top]:border data-[side=top]:border-t"
>
<SheetHeader>
<SheetTitle>Hapus {nama}?</SheetTitle>
<SheetDescription>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus {nama}?</AlertDialogTitle>
<AlertDialogDescription>
Data pelanggan {kdCustomer} akan dihapus permanen.
</SheetDescription>
</SheetHeader>
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction} className="p-4 pt-0">
<form action={formAction}>
<input type="hidden" name="kdCustomer" value={kdCustomer} />
{state.error && (
<p className="mb-2 text-sm text-destructive">{state.error}</p>
<p className="mb-4 text-sm text-destructive">{state.error}</p>
)}
<SheetFooter className="flex-row justify-end px-0">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={pending}
>
Batal
</Button>
<AlertDialogFooter>
<AlertDialogCancel disabled={pending}>Batal</AlertDialogCancel>
<Button type="submit" variant="destructive" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Hapus
</Button>
</SheetFooter>
</AlertDialogFooter>
</form>
</SheetContent>
</Sheet>
</AlertDialogContent>
</AlertDialog>
);
}

@ -1,6 +1,6 @@
"use client";
import { useActionState, useEffect, useState } from "react";
import { useActionState, useState } from "react";
import { Loader2Icon } from "lucide-react";
import {
@ -15,8 +15,8 @@ import {
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 "../action";
import { Pelanggan } from "@/data/pelanggan";
import { simpanPelanggan, type SimpanPelangganState } from "../actions";
import type { Pelanggan } from "@/data/pelanggan";
type PelangganFormProps = {
pelanggan?: Pelanggan;
@ -27,11 +27,16 @@ const initial: SimpanPelangganState = { ok: false };
export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(simpanPelanggan, initial);
useEffect(() => {
if (state.ok) setOpen(false);
}, [state.ok]);
// 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}>
@ -60,99 +65,80 @@ export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) {
/>
)}
<Field>
<FieldLabel htmlFor="nama">Nama *</FieldLabel>
<Input
id="nama"
name="nama"
defaultValue={pelanggan?.nama}
required
/>
<FieldError
errors={state.error ? [{ message: state.error }] : []}
/>
</Field>
<Field>
<FieldLabel htmlFor="alias">Alias</FieldLabel>
<Input id="alias" name="alias" defaultValue={pelanggan?.alias} />
</Field>
<Field>
<FieldLabel htmlFor="ktp">No. KTP</FieldLabel>
<Input id="ktp" name="ktp" defaultValue={pelanggan?.ktp} />
</Field>
<Field>
<FieldLabel htmlFor="alamat">Alamat *</FieldLabel>
<Input
id="alamat"
name="alamat"
defaultValue={pelanggan?.alamat}
required
/>
</Field>
<Field>
<FieldLabel htmlFor="wilayah">Wilayah</FieldLabel>
<Input
id="wilayah"
name="wilayah"
defaultValue={pelanggan?.wilayah ?? ""}
/>
</Field>
<Field>
<FieldLabel htmlFor="telp">Telepon</FieldLabel>
<Input id="telp" name="telp" defaultValue={pelanggan?.telp ?? ""} />
</Field>
<Field>
<FieldLabel htmlFor="ponsel">Ponsel</FieldLabel>
<Input
id="ponsel"
name="ponsel"
defaultValue={pelanggan?.ponsel ?? ""}
/>
</Field>
<Field>
<FieldLabel htmlFor="email">Email</FieldLabel>
<Input
id="email"
name="email"
type="email"
defaultValue={pelanggan?.email ?? ""}
/>
</Field>
<Field>
<FieldLabel htmlFor="jenisCustomer">Jenis Customer</FieldLabel>
<Input
id="jenisCustomer"
name="jenisCustomer"
defaultValue={pelanggan?.jenisCustomer ?? ""}
/>
</Field>
<Field>
<FieldLabel htmlFor="upline">Upline</FieldLabel>
<Input
id="upline"
name="upline"
defaultValue={pelanggan?.upline ?? ""}
/>
</Field>
<Field>
<FieldLabel htmlFor="btsBon">BTS Bon</FieldLabel>
<Input
id="btsBon"
name="btsBon"
type="number"
defaultValue={pelanggan?.btsBon ?? ""}
/>
</Field>
<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}>
@ -165,3 +151,41 @@ export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) {
</Sheet>
);
}
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}
/>
<FieldError errors={errors?.map((message) => ({ message }))} />
</Field>
);
}

@ -4,23 +4,15 @@ import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PelangganForm } from "./pelanggan-form";
import React from "react";
interface Props {
trigger?: React.ReactElement;
}
export function TambahPelangganButton({ trigger }: Props) {
export function TambahPelangganButton() {
return (
<PelangganForm
trigger={
trigger ? (
trigger
) : (
<Button>
Tambah
<PlusIcon />
</Button>
)
<Button>
Tambah
<PlusIcon />
</Button>
}
/>
);

@ -7,10 +7,18 @@ import {
deletePelanggan,
insertPelanggan,
updatePelanggan,
PelangganInput,
type PelangganInput,
} from "@/data/pelanggan";
export type SimpanPelangganState = { ok: boolean; error?: string; kdCustomer?: string };
type PelangganField = keyof typeof pelangganSchema.shape;
export type SimpanPelangganState = {
ok: boolean;
error?: string;
fieldErrors?: Partial<Record<PelangganField, string[]>>;
values?: Partial<Record<PelangganField, string>>;
kdCustomer?: string;
};
export type HapusPelangganState = { ok: boolean; error?: string };
const pelangganSchema = z.object({
@ -31,25 +39,24 @@ function emptyStringToNull(value: string): string | null {
return value === "" ? null : value;
}
export async function simpanPelanggan(_prevState: SimpanPelangganState, formData: FormData): Promise<SimpanPelangganState> {
await getCurrentUser();
export async function simpanPelanggan(
_prevState: SimpanPelangganState,
formData: FormData,
): Promise<SimpanPelangganState> {
const user = await getCurrentUser();
const parsed = pelangganSchema.safeParse({
nama: formData.get("nama"),
alias: formData.get("alias"),
ktp: formData.get("ktp"),
alamat: formData.get("alamat"),
wilayah: formData.get("wilayah"),
telp: formData.get("telp"),
ponsel: formData.get("ponsel"),
email: formData.get("email"),
jenisCustomer: formData.get("jenisCustomer"),
upline: formData.get("upline"),
btsBon: formData.get("btsBon"),
})
const fields = Object.keys(pelangganSchema.shape) as PelangganField[];
const values = Object.fromEntries(
fields.map((field) => [field, String(formData.get(field) ?? "")]),
) as Record<PelangganField, string>;
const parsed = pelangganSchema.safeParse(values);
if (!parsed.success) {
return { ok: false, error: parsed.error.issues[0].message }
return {
ok: false,
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values,
};
}
const input: PelangganInput = {
@ -60,8 +67,7 @@ export async function simpanPelanggan(_prevState: SimpanPelangganState, formData
email: emptyStringToNull(parsed.data.email),
jenisCustomer: emptyStringToNull(parsed.data.jenisCustomer),
upline: emptyStringToNull(parsed.data.upline),
}
};
const kdCustomer = String(formData.get("kdCustomer") ?? "").trim();
try {
@ -73,13 +79,21 @@ export async function simpanPelanggan(_prevState: SimpanPelangganState, formData
const updated = await updatePelanggan(kdCustomer, input);
if (!updated) {
return { ok: false, error: "Pelanggan tidak ditemukan" };
return { ok: false, error: "Pelanggan tidak ditemukan", values };
}
revalidatePath("/master/pelanggan");
return { ok: true, kdCustomer };
} catch (error) {
console.error("simpan pelanggan gagal", error);
return { ok: false, error: "Gagal menyimpan pelanggan" };
console.error(
"[simpanPelanggan] gagal",
{ userId: user.id, kdCustomer: kdCustomer || "(baru)", nama: input.nama },
error,
);
return {
ok: false,
error: "Gagal menyimpan pelanggan, coba simpan ulang",
values,
};
}
}
@ -87,7 +101,7 @@ export async function hapusPelanggan(
_prevState: HapusPelangganState,
formData: FormData,
): Promise<HapusPelangganState> {
await getCurrentUser();
const user = await getCurrentUser();
const kdCustomer = String(formData.get("kdCustomer") ?? "").trim();
if (kdCustomer === "") {
@ -102,7 +116,14 @@ export async function hapusPelanggan(
revalidatePath("/master/pelanggan");
return { ok: true };
} catch (error) {
console.error("hapus pelanggan gagal", error);
return { ok: false, error: "Gagal menghapus pelanggan" };
console.error(
"[hapusPelanggan] gagal",
{ userId: user.id, kdCustomer },
error,
);
return {
ok: false,
error: "Gagal menghapus pelanggan, coba hapus ulang",
};
}
}
}

@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
import { cn } from "cn"
import { Button } from "@/components/ui/button"
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: AlertDialogPrimitive.Backdrop.Props) {
return (
<AlertDialogPrimitive.Backdrop
data-slot="alert-dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
size = "default",
...props
}: AlertDialogPrimitive.Popup.Props & {
size?: "default" | "sm"
}) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Popup
data-slot="alert-dialog-content"
data-size={size}
className={cn(
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn(
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogMedia({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-media"
className={cn(
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
className
)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn(
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="alert-dialog-action"
className={cn(className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
variant = "outline",
size = "default",
...props
}: AlertDialogPrimitive.Close.Props &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
return (
<AlertDialogPrimitive.Close
data-slot="alert-dialog-cancel"
className={cn(className)}
render={<Button variant={variant} size={size} />}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogMedia,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
}

@ -20,43 +20,8 @@ export type Pelanggan = {
nilaiBon: number | null;
};
export type PelangganInput = {
nama: string;
alias: string;
ktp: string;
alamat: string;
wilayah: string | null;
telp: string | null;
ponsel: string | null;
email: string | null;
jenisCustomer: string | null;
upline: string | null;
btsBon: number | null;
};
export async function getPelanggan(
kdCustomer: string,
): Promise<PelangganInput | null> {
const [row] = await amigoDb
.select({
nama: pelanggan.nama,
alias: pelanggan.alias,
ktp: pelanggan.ktp,
alamat: pelanggan.alamat,
wilayah: pelanggan.wilayah,
telp: pelanggan.telp,
ponsel: pelanggan.ponsel,
email: pelanggan.email,
jenisCustomer: pelanggan.jenis_customer,
upline: pelanggan.upline,
btsBon: pelanggan.bts_bon,
})
.from(pelanggan)
.where(eq(pelanggan.kd_customer, kdCustomer))
.limit(1);
return row ?? null;
}
// kdCustomer dibuat sistem, nilaiBon diisi transaksi BON; keduanya bukan isian form.
export type PelangganInput = Omit<Pelanggan, "kdCustomer" | "nilaiBon">;
// Tanggal hari ini di zona toko (WIB) sebagai `yyyy-mm-dd`; toISOString() memakai UTC
// sehingga jam 00:00-07:00 tersimpan sebagai tanggal kemarin.
@ -175,9 +140,9 @@ export async function listPelanggan(search?: string): Promise<Pelanggan[]> {
.where(
q
? or(
like(pelanggan.nama, pattern),
like(pelanggan.kd_customer, pattern),
)
like(pelanggan.nama, pattern),
like(pelanggan.kd_customer, pattern),
)
: undefined,
)
.orderBy(asc(pelanggan.nama))

Loading…
Cancel
Save