Add customer master page with list, add, edit, delete, and search

main
Deardo Satria 2 days ago
parent 6abd3051c5
commit b0bf1d5bf7

@ -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" }],
},
];

@ -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 (
<div className="relative">
<SearchIcon className="absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-8"
placeholder="Cari pelanggan..."
value={nilai}
onChange={(e) => setNilai(e.target.value)}
/>
</div>
);
}

@ -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 (
<Sheet open={open} onOpenChange={setOpen}>
<Button
variant="ghost"
size="icon-sm"
onClick={() => setOpen(true)}
className="text-destructive hover:bg-destructive/10"
title="Hapus pelanggan"
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</Button>
<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>
Data pelanggan {kdCustomer} akan dihapus permanen.
</SheetDescription>
</SheetHeader>
<form action={formAction} className="p-4 pt-0">
<input type="hidden" name="kdCustomer" value={kdCustomer} />
{state.error && (
<p className="mb-2 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>
<Button type="submit" variant="destructive" disabled={pending}>
{pending && <Loader2Icon className="size-4 animate-spin" />}
Hapus
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

@ -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 (
<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}
/>
)}
<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>
<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,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 (
<PelangganForm
trigger={
trigger ? (
trigger
) : (
<Button>
Tambah
<PlusIcon />
</Button>
)
}
/>
);
}

@ -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<SimpanPelangganState> {
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<HapusPelangganState> {
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" };
}
}

@ -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 (
<div className="p-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Data Pelanggan</h1>
<div className="flex items-center gap-2">
<Suspense fallback={null}>
<CariPelanggan />
</Suspense>
<TambahPelangganButton />
</div>
</div>
<Suspense fallback={<p className="mt-4 text-sm">Memuat...</p>}>
<DaftarPelanggan searchParams={searchParams} />
</Suspense>
</div>
);
}
async function DaftarPelanggan({
searchParams,
}: {
searchParams: Promise<{ search?: string }>;
}) {
const { search } = await searchParams;
const daftar = await listPelanggan(search);
return (
<table className="mt-4 w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="py-2 pr-4">Kode</th>
<th className="py-2 pr-4">Nama</th>
<th className="py-2 pr-4">Alias</th>
<th className="py-2 pr-4">Alamat</th>
<th className="py-2 pr-4">Ponsel</th>
<th className="py-2 pr-4">Bon</th>
<th className="py-2 pr-4">Aksi</th>
</tr>
</thead>
<tbody>
{daftar.map((p) => (
<tr key={p.kdCustomer} className="border-b">
<td className="py-2 pr-4">{p.kdCustomer}</td>
<td className="py-2 pr-4">{p.nama}</td>
<td className="py-2 pr-4">{p.alias}</td>
<td className="py-2 pr-4">{p.alamat}</td>
<td className="py-2 pr-4">{p.ponsel ?? "-"}</td>
<td className="py-2 pr-4">
{p.nilaiBon !== null && p.nilaiBon > 0 ? p.nilaiBon : "-"}
</td>
<td className="flex gap-1 py-2 pr-4">
<PelangganForm
pelanggan={p}
trigger={
<Button variant="ghost" size="icon-sm" title="Edit pelanggan">
<PencilIcon />
<span className="sr-only">Edit</span>
</Button>
}
/>
<HapusPelangganButton kdCustomer={p.kdCustomer} nama={p.nama} />
</td>
</tr>
))}
{daftar.length === 0 && (
<tr>
<td colSpan={7} className="py-4 text-center text-muted-foreground">
Tidak ada pelanggan
</td>
</tr>
)}
</tbody>
</table>
);
}

@ -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<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;
}
// 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<string> {
// 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<boolean> {
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<boolean> {
const [res] = await amigoDb
.delete(pelanggan)
.where(eq(pelanggan.kd_customer, kdCustomer));
return res.affectedRows > 0;
}
export async function listPelanggan(search?: string): Promise<Pelanggan[]> {
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<string> {
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")}`;
}
Loading…
Cancel
Save