From b0bf1d5bf705ac7da7b86ad4669876d03c5c63ec Mon Sep 17 00:00:00 2001 From: Deardo Satria Date: Sun, 1 Nov 2026 15:39:15 +0700 Subject: [PATCH] Add customer master page with list, add, edit, delete, and search --- app/(dashboard)/_components/nav-items.ts | 2 +- .../pelanggan/_components/cari-pelanggan.tsx | 38 ++++ .../_components/hapus-pelanggan-button.tsx | 82 ++++++++ .../pelanggan/_components/pelanggan-form.tsx | 167 +++++++++++++++ .../_components/tambah-pelanggan-button.tsx | 27 +++ app/(dashboard)/master/pelanggan/action.ts | 108 ++++++++++ app/(dashboard)/master/pelanggan/page.tsx | 90 ++++++++ data/pelanggan.ts | 198 ++++++++++++++++++ 8 files changed, 711 insertions(+), 1 deletion(-) create mode 100644 app/(dashboard)/master/pelanggan/_components/cari-pelanggan.tsx create mode 100644 app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx create mode 100644 app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx create mode 100644 app/(dashboard)/master/pelanggan/_components/tambah-pelanggan-button.tsx create mode 100644 app/(dashboard)/master/pelanggan/action.ts create mode 100644 app/(dashboard)/master/pelanggan/page.tsx create mode 100644 data/pelanggan.ts diff --git a/app/(dashboard)/_components/nav-items.ts b/app/(dashboard)/_components/nav-items.ts index d44a760..dd195ac 100644 --- a/app/(dashboard)/_components/nav-items.ts +++ b/app/(dashboard)/_components/nav-items.ts @@ -19,6 +19,6 @@ export const navItems: NavItem[] = [ { title: "Master Data", icon: DatabaseIcon, - items: [{ title: "Satuan Barang", href: "/master/satuan-barang" }], + items: [{ title: "Satuan Barang", href: "/master/satuan-barang" }, { title: "Data Pelanggan", href: "/master/pelanggan" }], }, ]; diff --git a/app/(dashboard)/master/pelanggan/_components/cari-pelanggan.tsx b/app/(dashboard)/master/pelanggan/_components/cari-pelanggan.tsx new file mode 100644 index 0000000..eca891d --- /dev/null +++ b/app/(dashboard)/master/pelanggan/_components/cari-pelanggan.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { SearchIcon } from "lucide-react"; + +import { Input } from "@/components/ui/input"; + +export function CariPelanggan() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [nilai, setNilai] = useState(searchParams.get("search") ?? ""); + + useEffect(() => { + const current = searchParams.get("search") ?? ""; + if (nilai === current) return; + + const timer = setTimeout(() => { + const url = nilai + ? `/master/pelanggan?search=${encodeURIComponent(nilai)}` + : "/master/pelanggan"; + router.replace(url); + }, 300); + return () => clearTimeout(timer); + }, [nilai, searchParams, router]); + + return ( +
+ + setNilai(e.target.value)} + /> +
+ ); +} diff --git a/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx b/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx new file mode 100644 index 0000000..a8b89e7 --- /dev/null +++ b/app/(dashboard)/master/pelanggan/_components/hapus-pelanggan-button.tsx @@ -0,0 +1,82 @@ +"use client"; + +import { useActionState, useEffect, 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"; + +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(hapusPelanggan, initial); + + useEffect(() => { + if (state.ok) setOpen(false); + }, [state.ok]); + + return ( + + + + + + Hapus {nama}? + + Data pelanggan {kdCustomer} akan dihapus permanen. + + + +
+ + {state.error && ( +

{state.error}

+ )} + + + + +
+
+
+ ); +} diff --git a/app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx b/app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx new file mode 100644 index 0000000..ee93add --- /dev/null +++ b/app/(dashboard)/master/pelanggan/_components/pelanggan-form.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useActionState, useEffect, 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 "../action"; +import { Pelanggan } from "@/data/pelanggan"; + +type PelangganFormProps = { + pelanggan?: Pelanggan; + trigger: React.ReactElement; +}; + +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]); + + return ( + + + + + + {pelanggan ? `Edit ${pelanggan.nama}` : "Tambah Pelanggan"} + + + {pelanggan + ? `Kode: ${pelanggan.kdCustomer}` + : "Isi detail pelanggan baru"} + + + +
+ {pelanggan && ( + + )} + + + Nama * + + + + + + Alias + + + + + No. KTP + + + + + Alamat * + + + + + Wilayah + + + + + Telepon + + + + + Ponsel + + + + + Email + + + + + Jenis Customer + + + + + Upline + + + + + BTS Bon + + + + + + +
+
+
+ ); +} diff --git a/app/(dashboard)/master/pelanggan/_components/tambah-pelanggan-button.tsx b/app/(dashboard)/master/pelanggan/_components/tambah-pelanggan-button.tsx new file mode 100644 index 0000000..f1bc007 --- /dev/null +++ b/app/(dashboard)/master/pelanggan/_components/tambah-pelanggan-button.tsx @@ -0,0 +1,27 @@ +"use client"; + +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) { + return ( + + Tambah + + + ) + } + /> + ); +} diff --git a/app/(dashboard)/master/pelanggan/action.ts b/app/(dashboard)/master/pelanggan/action.ts new file mode 100644 index 0000000..be06706 --- /dev/null +++ b/app/(dashboard)/master/pelanggan/action.ts @@ -0,0 +1,108 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { z } from "zod"; +import { getCurrentUser } from "@/data/auth"; +import { + deletePelanggan, + insertPelanggan, + updatePelanggan, + PelangganInput, +} from "@/data/pelanggan"; + +export type SimpanPelangganState = { ok: boolean; error?: string; kdCustomer?: string }; +export type HapusPelangganState = { ok: boolean; error?: string }; + +const pelangganSchema = z.object({ + nama: z.string().trim().min(1, "Nama wajib diisi").max(100), + alias: z.string().trim().max(11), + ktp: z.string().trim().max(16), + alamat: z.string().trim().min(1, "Alamat wajib diisi").max(100), + wilayah: z.string().trim().max(200), + telp: z.string().trim().max(15), + ponsel: z.string().trim().max(15), + email: z.string().trim().max(30), + jenisCustomer: z.string().trim().max(1), + upline: z.string().trim().max(11), + btsBon: z.coerce.number().int().min(0).max(2_000_000_000), +}); + +function emptyStringToNull(value: string): string | null { + return value === "" ? null : value; +} + +export async function simpanPelanggan(_prevState: SimpanPelangganState, formData: FormData): Promise { + 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"), + }) + + if (!parsed.success) { + return { ok: false, error: parsed.error.issues[0].message } + } + + const input: PelangganInput = { + ...parsed.data, + wilayah: emptyStringToNull(parsed.data.wilayah), + telp: emptyStringToNull(parsed.data.telp), + ponsel: emptyStringToNull(parsed.data.ponsel), + email: emptyStringToNull(parsed.data.email), + jenisCustomer: emptyStringToNull(parsed.data.jenisCustomer), + upline: emptyStringToNull(parsed.data.upline), + } + + const kdCustomer = String(formData.get("kdCustomer") ?? "").trim(); + + try { + if (kdCustomer === "") { + const kdBaru = await insertPelanggan(input); + revalidatePath("/master/pelanggan"); + return { ok: true, kdCustomer: kdBaru }; + } + + const updated = await updatePelanggan(kdCustomer, input); + if (!updated) { + return { ok: false, error: "Pelanggan tidak ditemukan" }; + } + revalidatePath("/master/pelanggan"); + return { ok: true, kdCustomer }; + } catch (error) { + console.error("simpan pelanggan gagal", error); + return { ok: false, error: "Gagal menyimpan pelanggan" }; + } +} + +export async function hapusPelanggan( + _prevState: HapusPelangganState, + formData: FormData, +): Promise { + await getCurrentUser(); + + const kdCustomer = String(formData.get("kdCustomer") ?? "").trim(); + if (kdCustomer === "") { + return { ok: false, error: "Kode pelanggan tidak ada" }; + } + + try { + const deleted = await deletePelanggan(kdCustomer); + if (!deleted) { + return { ok: false, error: "Pelanggan tidak ditemukan" }; + } + revalidatePath("/master/pelanggan"); + return { ok: true }; + } catch (error) { + console.error("hapus pelanggan gagal", error); + return { ok: false, error: "Gagal menghapus pelanggan" }; + } +} \ No newline at end of file diff --git a/app/(dashboard)/master/pelanggan/page.tsx b/app/(dashboard)/master/pelanggan/page.tsx new file mode 100644 index 0000000..ca18cbf --- /dev/null +++ b/app/(dashboard)/master/pelanggan/page.tsx @@ -0,0 +1,90 @@ +import { Suspense } from "react"; +import { PencilIcon } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { listPelanggan } from "@/data/pelanggan"; +import { HapusPelangganButton } from "./_components/hapus-pelanggan-button"; +import { PelangganForm } from "./_components/pelanggan-form"; +import { TambahPelangganButton } from "./_components/tambah-pelanggan-button"; +import { CariPelanggan } from "./_components/cari-pelanggan"; + +export default function PelangganPage({ + searchParams, +}: { + searchParams: Promise<{ search?: string }>; +}) { + return ( +
+
+

Data Pelanggan

+
+ + + + +
+
+ Memuat...

}> + +
+
+ ); +} + +async function DaftarPelanggan({ + searchParams, +}: { + searchParams: Promise<{ search?: string }>; +}) { + const { search } = await searchParams; + const daftar = await listPelanggan(search); + + return ( + + + + + + + + + + + + + + {daftar.map((p) => ( + + + + + + + + + + ))} + {daftar.length === 0 && ( + + + + )} + +
KodeNamaAliasAlamatPonselBonAksi
{p.kdCustomer}{p.nama}{p.alias}{p.alamat}{p.ponsel ?? "-"} + {p.nilaiBon !== null && p.nilaiBon > 0 ? p.nilaiBon : "-"} + + + + Edit + + } + /> + +
+ Tidak ada pelanggan +
+ ); +} diff --git a/data/pelanggan.ts b/data/pelanggan.ts new file mode 100644 index 0000000..9422c46 --- /dev/null +++ b/data/pelanggan.ts @@ -0,0 +1,198 @@ +import "server-only"; + +import { asc, desc, like, or, eq } from "drizzle-orm"; +import { amigoDb } from "@/db/amigo"; +import { pelanggan } from "@/db/amigo/schema"; + +export type Pelanggan = { + kdCustomer: string; + 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; + 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 { + 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; +} + +// Tanggal hari ini di zona toko (WIB) sebagai `yyyy-mm-dd`; toISOString() memakai UTC +// sehingga jam 00:00-07:00 tersimpan sebagai tanggal kemarin. +function todayWib(): string { + return new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Jakarta" }).format( + new Date(), + ); +} + +// `\`, `%`, dan `_` adalah karakter khusus LIKE (escape default MariaDB `\`). +function escapeLike(value: string): string { + return value.replace(/[\\%_]/g, "\\$&"); +} + +function isKdCustomerExist(error: unknown): boolean { + const e = error as { + errno?: number; + code?: string; + cause?: { errno?: number; code?: string }; + }; + + return ( + e?.errno === 1062 || + e?.code === "ER_DUP_ENTRY" || + e?.cause?.errno === 1062 || + e?.cause?.code === "ER_DUP_ENTRY" + ); +} + +export async function insertPelanggan(input: PelangganInput): Promise { + // MyISAM tidak punya transaksi; baca max + insert bisa balapan dengan PHP lama, + // jadi kalau kode sudah dipakai proses lain, ulangi baca max + insert. + for (let attempt = 0; attempt < 3; attempt++) { + const kdCustomer = await generateKodePelanggan(); + try { + await amigoDb.insert(pelanggan).values({ + kd_customer: kdCustomer, + alias: input.alias, + ktp: input.ktp, + // PHP lama tidak mengisi `member`, jadi selalu tanggal nol. + member: "0000-00-00", + nama: input.nama, + alamat: input.alamat, + wilayah: input.wilayah, + telp: input.telp, + ponsel: input.ponsel, + email: input.email, + upline: input.upline, + jenis_customer: input.jenisCustomer, + bts_bon: input.btsBon, + tgl_entry: todayWib(), + }); + + return kdCustomer; + } catch (error) { + if (!isKdCustomerExist(error) || attempt === 2) throw error; + } + } + + throw new Error("Kode pelanggan baru sedang dipakai proses lain"); +} + +export async function updatePelanggan( + kdCustomer: string, + input: PelangganInput, +): Promise { + const [res] = await amigoDb + .update(pelanggan) + .set({ + alias: input.alias, + ktp: input.ktp, + nama: input.nama, + alamat: input.alamat, + wilayah: input.wilayah, + telp: input.telp, + ponsel: input.ponsel, + email: input.email, + upline: input.upline, + jenis_customer: input.jenisCustomer, + bts_bon: input.btsBon, + }) + .where(eq(pelanggan.kd_customer, kdCustomer)); + + return res.affectedRows > 0; +} + +export async function deletePelanggan(kdCustomer: string): Promise { + const [res] = await amigoDb + .delete(pelanggan) + .where(eq(pelanggan.kd_customer, kdCustomer)); + + return res.affectedRows > 0; +} + +export async function listPelanggan(search?: string): Promise { + const q = search?.trim(); + const pattern = q ? `%${escapeLike(q)}%` : ""; + + return amigoDb + .select({ + kdCustomer: pelanggan.kd_customer, + nama: pelanggan.nama, + alias: pelanggan.alias, + email: pelanggan.email, + alamat: pelanggan.alamat, + telp: pelanggan.telp, + ponsel: pelanggan.ponsel, + jenisCustomer: pelanggan.jenis_customer, + btsBon: pelanggan.bts_bon, + nilaiBon: pelanggan.nilai_bon, + ktp: pelanggan.ktp, + wilayah: pelanggan.wilayah, + upline: pelanggan.upline, + }) + .from(pelanggan) + .where( + q + ? or( + like(pelanggan.nama, pattern), + like(pelanggan.kd_customer, pattern), + ) + : undefined, + ) + .orderBy(asc(pelanggan.nama)) + .limit(100); +} + +// Kode pelanggan = "A8" + 6 digit urut, pola yang sama dengan aplikasi lama +// (old-source/ajaxpelanggan.php ambil_kode). +export async function generateKodePelanggan(): Promise { + const [row] = await amigoDb + .select({ kdCustomer: pelanggan.kd_customer }) + .from(pelanggan) + .orderBy(desc(pelanggan.kd_customer)) + .limit(1); + + const urut = Number.parseInt(row?.kdCustomer.slice(2) ?? "0", 10) + 1; + return `A8${String(urut).padStart(6, "0")}`; +}