From e1c6104004e95332a5e0efd9f470871d9dc89bed Mon Sep 17 00:00:00 2001 From: Deardo Satria Date: Sun, 1 Nov 2026 16:17:37 +0700 Subject: [PATCH] Fix customer form state, delete dialog, and action conventions --- app/(dashboard)/_components/nav-items.ts | 5 +- .../_components/hapus-pelanggan-button.tsx | 88 ++++--- .../pelanggan/_components/pelanggan-form.tsx | 226 ++++++++++-------- .../_components/tambah-pelanggan-button.tsx | 18 +- .../pelanggan/{action.ts => actions.ts} | 75 +++--- components/ui/alert-dialog.tsx | 187 +++++++++++++++ data/pelanggan.ts | 45 +--- 7 files changed, 417 insertions(+), 227 deletions(-) rename app/(dashboard)/master/pelanggan/{action.ts => actions.ts} (61%) create mode 100644 components/ui/alert-dialog.tsx diff --git a/app/(dashboard)/_components/nav-items.ts b/app/(dashboard)/_components/nav-items.ts index dd195ac..28ca6d0 100644 --- a/app/(dashboard)/_components/nav-items.ts +++ b/app/(dashboard)/_components/nav-items.ts @@ -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" }, + ], }, ]; diff --git a/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx b/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx index a8b89e7..b611980 100644 --- a/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx +++ b/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx @@ -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 ( - - + - - - Hapus {nama}? - + + + Hapus {nama}? + Data pelanggan {kdCustomer} akan dihapus permanen. - - + + -
+ {state.error && ( -

{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 ee93add..2e26d82 100644 --- a/app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx +++ b/app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx @@ -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 ( @@ -60,99 +65,80 @@ export function PelangganForm({ pelanggan, trigger }: PelangganFormProps) { /> )} - - Nama * - - - - - - Alias - - - - - No. KTP - - - - - Alamat * - - - - - Wilayah - - - - - Telepon - - - - - Ponsel - - - - - Email - - - - - Jenis Customer - - - - - Upline - - - - - BTS Bon - - + + + + + + + + + + + + + {state.error && ( +

{state.error}

+ )} - ) + } /> ); diff --git a/app/(dashboard)/master/pelanggan/action.ts b/app/(dashboard)/master/pelanggan/actions.ts similarity index 61% rename from app/(dashboard)/master/pelanggan/action.ts rename to app/(dashboard)/master/pelanggan/actions.ts index be06706..ae111b5 100644 --- a/app/(dashboard)/master/pelanggan/action.ts +++ b/app/(dashboard)/master/pelanggan/actions.ts @@ -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>; + values?: Partial>; + 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 { - await getCurrentUser(); +export async function simpanPelanggan( + _prevState: SimpanPelangganState, + formData: FormData, +): Promise { + 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; + 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 { - 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", + }; } -} \ No newline at end of file +} diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..4d88edf --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -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 +} + +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ) +} + +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: AlertDialogPrimitive.Backdrop.Props) { + return ( + + ) +} + +function AlertDialogContent({ + className, + size = "default", + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: "default" | "sm" +}) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( +