Compare commits

..

No commits in common. '5cd49bb24b1dedef7b800c54972e1334273373a6' and '6abd3051c5ab76edbbdbb09540e2a332765870cc' have entirely different histories.

@ -1,111 +0,0 @@
---
name: cleo-code-review
description: Review struktur dan penulisan kode Cleo Minimarket (Next.js 16, Server Actions, Drizzle, MariaDB MyISAM) terhadap aturan CLAUDE.md dan bug yang pernah ditemukan. Pakai saat diminta "review", "cek kode", "cek commit yang belum dipush", atau sebelum push.
---
# cleo-code-review
Review read-only atas perubahan kode di proyek ini. Tujuannya menangkap bug nyata dan
pelanggaran aturan proyek sebelum di-push. Jangan memperbaiki file kecuali diminta.
## 1. Tentukan cakupan
- Default: commit yang belum di-push plus perubahan yang belum di-commit.
```
git status -sb
git log --oneline '@{u}..HEAD'
git diff --stat '@{u}'
git diff '@{u}'
```
- Kalau user menyebut commit, branch, atau file tertentu, pakai itu.
- Abaikan `old-source/`, `backup/`, `drizzle/`, `components/ui/`, `db/amigo/schema.ts`
(referensi, backup, atau generated), kecuali perubahannya memang di sana dan mencurigakan.
## 2. Jalankan pengecekan otomatis
```
pnpm exec tsc --noEmit
pnpm lint
pnpm exec prettier --check <file yang berubah>
pnpm build
```
Kutip baris error terpendek yang menentukan. Build perlu dijalankan karena `cacheComponents`
membuat error Suspense baru muncul di build, bukan di `tsc`.
## 3. Baca diff dengan checklist ini
Untuk temuan di sekitar kode, baca file utuhnya, jangan hanya potongan diff.
### Auth dan routing
- `proxy.ts` hanya redirect berdasarkan ada/tidaknya cookie. Redirect "sudah login -> /" di
proxy memicu loop saat cookie kedaluwarsa (proxy kirim ke `/`, `getCurrentUser()` kirim ke
`/login`). Redirect seperti itu harus memakai session yang divalidasi
(`redirectIfLoggedIn()` di `data/auth.ts`), dipanggil dari page di dalam `<Suspense>`.
- Matcher proxy harus tetap mengecualikan `login`, `api`, dan aset statis.
- Import dari path internal Next (`next/dist/...`) hampir selalu salah.
- Page dan action yang butuh user memanggil `getCurrentUser()`; layout tidak dipakai untuk cek
session.
- Komponen yang membaca `headers()`/`cookies()`/`searchParams` berada di dalam `<Suspense>`.
### Server Actions
- File bernama `actions.ts` (jamak), diawali `"use server"`, dikolokasikan dengan fitur.
- Setiap action: cek session, validasi zod sebelum menyentuh DB, panggil fungsi `data/`
(bukan query langsung), kembalikan objek kecil, `revalidatePath` sebelum `redirect`.
- Error validasi dikembalikan per field (`z.flattenError(err).fieldErrors`), bukan hanya
`issues[0].message`; kalau tidak, pesan tampil di field yang salah.
- Return yang gagal ikut membawa `values` (isian mentah), karena React 19 me-reset form
setelah action selesai dan ketikan user hilang.
- `console.error` berisi tag nama action, `user.id`, input relevan (tanpa password/secret),
dan error asli sebagai argumen terpisah. Pesan ke UI bahasa Indonesia dan bisa
ditindaklanjuti, tidak pernah pesan DB mentah.
- Hasil `getCurrentUser()` disimpan ke variabel kalau dipakai di log.
### Komponen client
- Jangan menutup sheet/dialog lewat `useEffect(() => { if (state.ok) setOpen(false) },
[state.ok])`: tidak jalan lagi saat sukses kedua (true -> true) dan dilarang lint
`react-hooks/set-state-in-effect`. Bungkus action di `useActionState` dan panggil
`setOpen(false)` di sana.
- Input form memakai `defaultValue={state.values?.[name] ?? dataAwal ?? ""}`; error per field
ditampilkan di `<FieldError>` field itu, error umum di dekat tombol submit.
- Tipe dari modul `data/` (server-only) di-import dengan `import type`.
- Konfirmasi destruktif memakai `AlertDialog`, bukan `Sheet` yang di-override class-nya.
Tombol konfirmasi berupa `Button type="submit"` supaya error action sempat terlihat.
- Prop yang tidak dipakai pemanggil mana pun dibuang.
### Data Access Layer (`data/`)
- Diawali `import "server-only"`.
- Fungsi yang diekspor tapi tidak dipakai di mana pun: tandai untuk dihapus.
- Tipe input diturunkan dari tipe baris (`Omit<...>`) daripada ditulis ulang manual.
- Query lewat Drizzle atau `sql` tag, tidak pernah string concatenation. Pola `LIKE` memakai
escape `\`, `%`, `_`.
- MariaDB `cleo` itu MyISAM: tidak ada transaksi dan `FOR UPDATE` tidak mengunci. Penomoran
otomatis harus tahan bentrok dengan PHP lama (retry saat `ER_DUP_ENTRY`), update stok
atomik satu statement, master + detail divalidasi penuh sebelum statement pertama.
- Tanggal "hari ini" pakai zona `Asia/Jakarta`, bukan `toISOString()` (UTC).
- Tanggal `0000-00-00` tetap string, tidak diubah ke `Date`.
- Batas panjang zod sama dengan panjang kolom di `db/amigo/schema.ts`.
- Aturan bisnis dicocokkan dengan kode lama di `old-source/` (mis. format kode pelanggan).
### Kerapian
- Format sesuai Prettier (titik koma, baris tidak terlalu panjang, newline di akhir file).
- Urutan import: package luar, baris kosong, lalu `@/...`, lalu path relatif.
- Nama file kebab-case, komponen PascalCase, import lintas folder lewat alias `@/`.
## 4. Laporkan
Kalimat pertama berisi kesimpulan: siap di-push atau belum, dan hasil `tsc`/`lint`/`build`.
Lalu temuan dikelompokkan dan diberi nomor berurutan lintas kelompok:
1. **Bug**: perilaku salah yang bisa dialami user. Sertakan skenario konkretnya.
2. **Tidak sesuai aturan proyek**: pelanggaran CLAUDE.md.
3. **Kerapian**: format, kode mati, duplikasi.
Setiap temuan menyebut `file:baris`, apa yang salah, dan arah perbaikannya dalam satu atau
dua kalimat. Tutup dengan daftar singkat hal yang sudah benar, lalu saran urutan perbaikan.
Jangan mengedit file; kalau user minta perbaikan, kerjakan atau pandu sesuai mode yang aktif.

3
.gitignore vendored

@ -48,6 +48,3 @@ next-env.d.ts
/db/amigo/*.sql
/db/amigo/meta/
/db/amigo/relations.ts
# local MariaDB dump backups
backup/

@ -1,6 +0,0 @@
old-source/
backup/
drizzle/
components/ui/
db/amigo/schema.ts
pnpm-lock.yaml

@ -1,7 +1,4 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import { redirectIfLoggedIn } from "@/data/auth";
import { LoginForm } from "./_components/login-form";
export const metadata: Metadata = {
@ -12,15 +9,8 @@ export default function LoginPage() {
return (
<main className="flex flex-1 items-center justify-center p-4">
<div className="w-full max-w-sm">
<Suspense fallback={null}>
<LoginContent />
</Suspense>
<LoginForm />
</div>
</main>
);
}
async function LoginContent() {
await redirectIfLoggedIn();
return <LoginForm />;
}

@ -19,9 +19,6 @@ 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" }],
},
];

@ -1,38 +0,0 @@
"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>
);
}

@ -1,80 +0,0 @@
"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";
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"
/>
}
>
<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 +0,0 @@
"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";
import type { 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);
// 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
? `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>
);
}
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>
);
}

@ -1,19 +0,0 @@
"use client";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PelangganForm } from "./pelanggan-form";
export function TambahPelangganButton() {
return (
<PelangganForm
trigger={
<Button>
Tambah
<PlusIcon />
</Button>
}
/>
);
}

@ -1,129 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getCurrentUser } from "@/data/auth";
import {
deletePelanggan,
insertPelanggan,
updatePelanggan,
type PelangganInput,
} from "@/data/pelanggan";
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({
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> {
const user = await getCurrentUser();
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,
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values,
};
}
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", values };
}
revalidatePath("/master/pelanggan");
return { ok: true, kdCustomer };
} catch (error) {
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,
};
}
}
export async function hapusPelanggan(
_prevState: HapusPelangganState,
formData: FormData,
): Promise<HapusPelangganState> {
const user = 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(
"[hapusPelanggan] gagal",
{ userId: user.id, kdCustomer },
error,
);
return {
ok: false,
error: "Gagal menghapus pelanggan, coba hapus ulang",
};
}
}

@ -1,90 +0,0 @@
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>
);
}

@ -1,187 +0,0 @@
"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,
}

@ -19,10 +19,3 @@ export async function getCurrentUser(): Promise<CurrentUser> {
username: session.user.username ?? null,
};
}
// Dipakai halaman login: session divalidasi (bukan cuma cek cookie) supaya
// cookie kedaluwarsa tidak memicu redirect bolak-balik dengan getCurrentUser().
export async function redirectIfLoggedIn(): Promise<void> {
const session = await auth.api.getSession({ headers: await headers() });
if (session) redirect("/");
}

@ -1,163 +0,0 @@
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;
};
// 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.
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")}`;
}

@ -34,5 +34,3 @@ volumes:
# Fixed name so the volume created earlier by `docker run -v cleo-pgdata:...` is reused.
name: cleo-pgdata
cleo-mariadb-data:
# Fixed name so the backup restore into the named volume is reused.
name: cleo-mariadb-data

@ -7,8 +7,6 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"format": "prettier --write .",
"format:check": "prettier --check .",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
@ -41,7 +39,6 @@
"drizzle-kit": "^0.31.11",
"eslint": "^9",
"eslint-config-next": "16.4.2",
"prettier": "^3.9.10",
"tailwindcss": "^4",
"tsx": "4.23.15",
"typescript": "^5"

@ -78,9 +78,6 @@ importers:
eslint-config-next:
specifier: 16.4.2
version: 16.4.2(@typescript-eslint/parser@8.71.2(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3)
prettier:
specifier: ^3.9.10
version: 3.9.10
tailwindcss:
specifier: ^4
version: 4.3.3
@ -3273,11 +3270,6 @@ packages:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
prettier@3.9.10:
resolution: {integrity: sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==}
engines: {node: '>=14'}
hasBin: true
pretty-ms@9.3.1:
resolution: {integrity: sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==}
engines: {node: '>=18'}
@ -6774,8 +6766,6 @@ snapshots:
prelude-ls@1.2.1: {}
prettier@3.9.10: {}
pretty-ms@9.3.1:
dependencies:
parse-ms: 4.0.0

Loading…
Cancel
Save