Compare commits

...

3 Commits

Author SHA1 Message Date
Deardo Satria 770ecbd204 Clarify mandatory checks in cleo-code-review skill
Mark steps 1-6 as ordered with mandatory mechanical checks (reference
check before delete, session chain, zod messages, import order), add the
CRLF caveat for prettier on Windows, and spell out the report format
with a concrete example.
22 hours ago
Deardo Satria bc7619210c Add master satuan barang feature
CRUD page for satuan barang following the pelanggan feature pattern:
page, actions, form sheet, delete dialog, and a data layer with LIKE
search and pagination. Delete rejects units still referenced by barang.
22 hours ago
Deardo Satria 6f1234aa49 Extract shared pagination, search box, and escapeLike helpers
Pull PER_PAGE, parsePage, and halamanHref into lib/pagination, the search
box into components/cari-master, and escapeLike into lib/utils so other
master features can reuse them. Pelanggan switches to these helpers and
its list now calls getCurrentUser() in its render chain.
22 hours ago

@ -8,6 +8,9 @@ description: Review struktur dan penulisan kode Cleo Minimarket (Next.js 16, Ser
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.
Kerjakan langkah 1 sampai 6 berurutan. Langkah 3 berisi perintah yang wajib dijalankan, bukan
sekadar dipertimbangkan; checklist di langkah 4 baru dibaca setelahnya.
## 1. Tentukan cakupan
- Default: commit yang belum di-push plus perubahan yang belum di-commit.
@ -17,6 +20,7 @@ pelanggaran aturan proyek sebelum di-push. Jangan memperbaiki file kecuali dimin
git diff --stat '@{u}'
git diff '@{u}'
```
File baru (`??` di `git status`) tidak muncul di `git diff`; baca isinya langsung.
- 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.
@ -33,10 +37,90 @@ 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
Kalau `prettier --check` gagal, pastikan dulu penyebabnya bukan akhir baris CRLF. Di Windows,
`core.autocrlf=true` membuat file di disk ber-CRLF walau isi di git tetap LF:
```
pnpm exec prettier --check --end-of-line auto <file yang sama>
git ls-files --eol <file yang sama>
```
Kalau perintah pertama lolos dan kolom `i/` di perintah kedua berisi `lf`, prettier dianggap
lolos. Itu bukan temuan, jangan sarankan `prettier --write` atau mengubah config prettier.
Laporkan sebagai temuan hanya kalau `i/crlf` (CRLF ikut ter-commit) atau `--end-of-line auto`
tetap gagal.
## 3. Pengecekan mekanis wajib
Jalankan setiap pengecekan yang relevan dengan diff, lalu catat hasilnya. Pelanggaran yang
juga ada di fitur acuan (`master/pelanggan`) tetap dilaporkan; sebutkan bahwa fitur acuan ikut
kena, jangan dijadikan alasan untuk lolos.
### 3a. Referensi sebelum hapus
Untuk setiap fungsi `delete<Fitur>` yang baru atau berubah, cari tabel lain yang menyimpan
kunci baris yang dihapus. Nama kolom penyimpannya sering berbeda dari nama kolom kunci
(mis. `no_barang.satuan` menyimpan `satuan_barang.idSatuanBarang`), jadi cari lewat nama
tabelnya di query lama:
```
grep -rn "<nama_tabel>" old-source --include=*.php
```
Baca query yang memakai tabel itu dalam `JOIN` atau `WHERE a.x = b.y`. Kalau ada tabel lain
yang menunjuk ke baris ini dan action hapus tidak mengecek pemakaian dulu, laporkan sebagai
**Bug**. Skenarionya: data di tabel lain jadi yatim, dan query `JOIN` di aplikasi PHP diam-diam
menyembunyikan baris itu. Kalau DB lokal menyala, hitung baris yatim yang sudah ada sebagai
bukti:
```
echo 'SELECT COUNT(*) FROM <tabel_lain> t LEFT JOIN <tabel> x ON t.<kolom> = x.<kunci> WHERE x.<kunci> IS NULL;' | docker exec -i cleo-mariadb sh -c 'mysql -uroot -p$MARIADB_ROOT_PASSWORD -N cleo'
```
### 3b. Session di setiap halaman dashboard
Setiap file di `app/(dashboard)/` yang memanggil fungsi `data/` (selain `data/auth`) untuk
dirender harus ikut memanggil `getCurrentUser()` di rantai render-nya, yaitu di page itu
sendiri atau di komponen server yang mengambil datanya:
```
grep -rn --include=*.tsx 'from "@/data/' "app/(dashboard)" | grep -v 'import type' | grep -v '@/data/auth'
grep -rln --include=*.tsx 'getCurrentUser' "app/(dashboard)"
```
Perintah pertama sudah membuang `import type` (hanya tipe, tidak membaca data). File yang
muncul di daftar pertama tapi rantai render-nya tidak ada di daftar kedua adalah
**Bug**. `proxy.ts` hanya mengecek ada/tidaknya cookie, dan `SidebarUser` di layout tidak
dirender ulang saat navigasi client, jadi cookie kedaluwarsa tetap bisa melihat data.
### 3c. Pesan zod
Setiap aturan zod yang bisa gagal karena isian user (`.min`, `.max`, `.regex`, `.email`,
`z.enum`, dan sejenisnya) harus membawa pesan bahasa Indonesia sendiri. Tanpa itu, zod
menampilkan pesan bawaan bahasa Inggris (mis. "Too big: expected string to have <=30
characters").
```
grep -rn --include=actions.ts '\.min(\|\.max(\|\.regex(\|\.email(\|z\.enum(' app
```
Baris yang argumennya hanya angka, mis. `.max(30)` tanpa pesan, adalah temuan.
### 3d. Urutan import
Untuk setiap file yang berubah, termasuk file lama yang hanya diubah sedikit, cek urutan:
package luar, baris kosong, `@/...`, lalu path relatif (`./`, `../`). Import `@/...` yang
ditulis setelah import relatif adalah temuan **Kerapian**.
## 4. Baca diff dengan checklist ini
Untuk temuan di sekitar kode, baca file utuhnya, jangan hanya potongan diff.
**"Sama dengan PHP lama" bukan alasan untuk lolos.** Aturan bisnis ditiru dari `old-source/`,
tapi kalau perilaku lama merusak data (hapus tanpa cek referensi, penomoran bentrok, stok
dihitung baca-hitung-tulis), tetap laporkan sebagai bug. Sebutkan bahwa PHP lama berperilaku
sama supaya pembaca tahu ini bukan regresi.
### Auth dan routing
- `proxy.ts` hanya redirect berdasarkan ada/tidaknya cookie. Redirect "sudah login -> /" di
@ -45,8 +129,8 @@ Untuk temuan di sekitar kode, baca file utuhnya, jangan hanya potongan diff.
(`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.
- Page dan action yang butuh user memanggil `getCurrentUser()` (lihat 3b); layout tidak
dipakai untuk cek session.
- Komponen yang membaca `headers()`/`cookies()`/`searchParams` berada di dalam `<Suspense>`.
### Server Actions
@ -56,6 +140,7 @@ Untuk temuan di sekitar kode, baca file utuhnya, jangan hanya potongan diff.
(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.
- Setiap aturan zod membawa pesan bahasa Indonesia (lihat 3c).
- 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),
@ -86,6 +171,7 @@ Untuk temuan di sekitar kode, baca file utuhnya, jangan hanya potongan diff.
- 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.
- Hapus baris yang masih direferensikan tabel lain harus ditolak dulu (lihat 3a).
- 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`.
@ -110,8 +196,9 @@ lain yang sejenis.
- `components/` (lintas fitur) tidak import dari `app/`.
- Duplikasi lintas fitur: kode yang sama muncul di fitur kedua dipindah ke satu tempat
bersama, bukan disalin. Kandidat umum: escape `LIKE`, tipe hasil action
(`{ ok, error, fieldErrors, values }`), pola log error action, pagination, kotak pencarian,
skema zod yang berulang. Jangan memindahkan saat baru dipakai satu fitur.
(`{ ok, error, fieldErrors, values }`), pola log error action, pagination (termasuk helper
`href` halaman dan parsing `?page=`), kotak pencarian, skema zod yang berulang. Jangan
memindahkan saat baru dipakai satu fitur.
- Aturan bisnis lama (kode huruf harga `convHrgP`/`convProfit`, format tanggal `dd-mm-yyyy`,
tanggal "hari ini" `Asia/Jakarta`) punya satu implementasi di `lib/`, tidak ditulis ulang per
fitur.
@ -120,25 +207,73 @@ lain yang sejenis.
daftar, dan dialog dalam satu file), ditandai untuk dipecah.
- Navigasi: setiap `href` di `app/(dashboard)/_components/nav-items.ts` punya `page.tsx`, dan
setiap page baru di `(dashboard)` terdaftar di nav (kecuali halaman detail/turunan).
- Kalau perubahan memperkenalkan konvensi baru (folder, helper bersama, pola action), cek
apakah `CLAUDE.md` ikut diperbarui supaya fitur berikutnya mengikuti pola yang sama.
- Kalau perubahan memperkenalkan konvensi baru (folder, helper bersama di `components/` atau
`lib/`, pola action), cek apakah `CLAUDE.md` ikut diperbarui supaya fitur berikutnya memakai
helper itu. Kalau belum, itu temuan **Tidak sesuai aturan proyek**.
### Kerapian
- Format sesuai Prettier (titik koma, baris tidak terlalu panjang, newline di akhir file).
- Urutan import: package luar, baris kosong, lalu `@/...`, lalu path relatif.
- Urutan import: package luar, baris kosong, lalu `@/...`, lalu path relatif (lihat 3d).
- Nama file kebab-case, komponen PascalCase, import lintas folder lewat alias `@/`.
## 4. Laporkan
## 5. Verifikasi sebelum melapor
Untuk setiap calon temuan:
1. Buka ulang file di baris yang akan disebut dan pastikan kodenya memang seperti yang
diklaim. Nomor baris harus cocok dengan isi file saat ini.
2. Untuk bug, pastikan skenarionya benar-benar bisa terjadi lewat jalur yang ada (UI, POST
langsung ke action, atau aplikasi PHP yang memakai tabel yang sama).
3. Buang temuan yang tidak terbukti. Temuan yang masuk akal tapi belum bisa dibuktikan boleh
tetap ditulis dengan penanda "perlu dicek", bukan dinyatakan pasti.
## 6. Laporkan
Ikuti aturan output user (bahasa, nada) dari CLAUDE.md global. Struktur laporannya:
Kalimat pertama berisi kesimpulan: siap di-push atau belum, dan hasil `tsc`/`lint`/`build`.
Lalu temuan dikelompokkan dan diberi nomor berurutan lintas kelompok:
- **Kalimat pertama adalah verdict**: "Siap di-push." atau "Belum siap di-push: <alasan
utama>.", diikuti hasil `tsc`/`lint`/`prettier`/`build` dalam satu kalimat. Jangan membuka
dengan "Sudah saya review...", "Berikut hasil review...", atau sejenisnya.
- Prettier yang gagal hanya karena CRLF (lihat langkah 2) ditulis "lolos", boleh disertai
satu kalimat penjelasan, dan tidak membuat verdict jadi "belum siap".
- Temuan dikelompokkan dan diberi nomor berurutan lintas kelompok:
1. **Bug**: perilaku salah yang bisa dialami user. Wajib ada skenario konkret.
2. **Tidak sesuai aturan proyek**: pelanggaran CLAUDE.md.
3. **Struktur dan maintainability**: menyimpang dari pola fitur acuan, arah dependency
salah, duplikasi lintas fitur, file terlalu besar, nav tidak sinkron.
4. **Kerapian**: format, urutan import, kode mati, duplikasi kecil di dalam satu file.
1. **Bug**: perilaku salah yang bisa dialami user. Sertakan skenario konkretnya.
2. **Tidak sesuai aturan proyek**: pelanggaran CLAUDE.md.
3. **Struktur dan maintainability**: menyimpang dari pola fitur acuan, arah dependency salah,
duplikasi lintas fitur, file terlalu besar, nav tidak sinkron.
4. **Kerapian**: format, kode mati, duplikasi kecil di dalam satu file.
Kelompok yang kosong tidak ditulis.
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.
- Setiap temuan menyebut `file:baris`, apa yang salah, dan arah perbaikannya dalam satu atau
dua kalimat. Bug boleh lebih panjang karena butuh skenario dan bukti.
- Tutup dengan daftar singkat hal yang sudah benar, lalu saran urutan perbaikan (nomor
temuan, bug lebih dulu), lalu satu tawaran konkret untuk dikerjakan berikutnya.
Contoh bentuk laporan (isi hanya ilustrasi):
```
Belum siap di-push: satuan yang masih dipakai barang bisa dihapus. tsc, lint, prettier,
dan build lolos (peringatan prettier hanya karena CRLF di disk, isi git tetap LF).
## Bug
1. **`data/satuan-barang.ts:46`**: `deleteSatuanBarang` tidak mengecek pemakaian di
`no_barang.satuan`. Skenario: user menghapus satuan "PCS", lalu semua barang bersatuan
PCS hilang dari daftar barang PHP karena query di `old-source/ajaxbarang.php:302` memakai
inner join. DB lokal sudah punya 12 baris yatim. PHP lama berperilaku sama, jadi ini
bukan regresi. Fix: hitung pemakaian dulu dan tolak dengan pesan "Satuan masih dipakai
N barang".
## Kerapian
2. **`app/(dashboard)/master/pelanggan/page.tsx:5`**: import `@/components/cari-master`
ditulis setelah import relatif. Pindahkan ke atas blok `./_components/...`.
## Yang sudah benar
- Action mengecek session, validasi zod, dan mengembalikan `fieldErrors` plus `values`.
Urutan perbaikan: 1, lalu 2. Mau saya kerjakan nomor 1 sekarang?
```

@ -70,6 +70,8 @@ app/
components/
ui/ komponen shadcn (generated, jangan diedit manual kecuali perlu)
<nama>.tsx komponen yang dipakai lintas fitur
cari-master.tsx kotak pencarian master (prop basePath + placeholder)
pagination.tsx tombol halaman daftar (prop page, hasNext, hrefPage)
data/ Data Access Layer: fungsi baca/tulis DB, selalu 'server-only'
db/
index.ts, schema.ts koneksi + schema Postgres
@ -77,7 +79,8 @@ db/
lib/
auth.ts config Better Auth (server)
auth-client.ts client Better Auth
utils.ts helper umum (dibuat shadcn)
pagination.ts PER_PAGE, parse `?page=`, dan href halaman
utils.ts helper umum: cn (dibuat shadcn) dan escapeLike untuk LIKE
drizzle/ file migrasi Postgres (generated, ikut di-commit)
proxy.ts pengganti middleware di Next 16; hanya cek ringan (redirect)
```

@ -2,7 +2,9 @@ import { PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Pagination } from "@/components/pagination";
import { getCurrentUser } from "@/data/auth";
import { listPelanggan } from "@/data/pelanggan";
import { halamanHref, parsePage } from "@/lib/pagination";
import { HapusPelangganButton } from "./hapus-pelanggan-button";
import { PelangganForm } from "./pelanggan-form";
@ -11,10 +13,10 @@ export async function DaftarPelanggan({
}: {
searchParams: Promise<{ search?: string; page?: string }>;
}) {
await getCurrentUser();
const { search, page: pageParam } = await searchParams;
const parsed = Number.parseInt(pageParam ?? "1", 10);
// Batas atas mencegah offset melebihi BIGINT MariaDB (query error).
const page = Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : 1;
const page = parsePage(pageParam);
const { rows, hasNext } = await listPelanggan(search, page);
return (
@ -95,16 +97,8 @@ export async function DaftarPelanggan({
<Pagination
page={page}
hasNext={hasNext}
hrefPage={(p) => halamanHref(p, search)}
hrefPage={(p) => halamanHref("/master/pelanggan", p, search)}
/>
</>
);
}
function halamanHref(page: number, search?: string): string {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (page > 1) params.set("page", String(page));
const query = params.toString();
return query ? `/master/pelanggan?${query}` : "/master/pelanggan";
}

@ -1,8 +1,8 @@
import { Suspense } from "react";
import { CariMaster } from "@/components/cari-master";
import { DaftarPelanggan } from "./_components/daftar-pelanggan";
import { TambahPelangganButton } from "./_components/tambah-pelanggan-button";
import { CariPelanggan } from "./_components/cari-pelanggan";
export default function PelangganPage({
searchParams,
@ -15,7 +15,10 @@ export default function PelangganPage({
<h1 className="text-lg font-semibold">Data Pelanggan</h1>
<div className="flex items-center gap-2">
<Suspense fallback={null}>
<CariPelanggan />
<CariMaster
basePath="/master/pelanggan"
placeholder="Cari pelanggan..."
/>
</Suspense>
<TambahPelangganButton />
</div>

@ -0,0 +1,90 @@
import { PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Pagination } from "@/components/pagination";
import { getCurrentUser } from "@/data/auth";
import { listSatuanBarang } from "@/data/satuan-barang";
import { halamanHref, parsePage } from "@/lib/pagination";
import { HapusSatuanBarangButton } from "./hapus-satuan-barang-button";
import { SatuanBarangForm } from "./satuan-barang-form";
export async function DaftarSatuanBarang({
searchParams,
}: {
searchParams: Promise<{ search?: string; page?: string }>;
}) {
await getCurrentUser();
const { search, page: pageParam } = await searchParams;
const page = parsePage(pageParam);
const { rows, hasNext } = await listSatuanBarang(search, page);
return (
<>
<div className="mt-4 overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50 text-left text-xs tracking-wide text-muted-foreground uppercase">
<th className="px-3 py-2.5 font-medium">ID</th>
<th className="px-3 py-2.5 font-medium">Nama Satuan</th>
<th className="px-3 py-2.5 text-right font-medium">Aksi</th>
</tr>
</thead>
<tbody className="divide-y">
{rows.map((s) => (
<tr
key={s.idSatuanBarang}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
{s.idSatuanBarang}
</td>
<td className="px-3 py-2 font-medium">
{s.namaSatuanBarang}
</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<SatuanBarangForm
satuan={s}
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Edit satuan"
>
<PencilIcon />
<span className="sr-only">Edit</span>
</Button>
}
/>
<HapusSatuanBarangButton
idSatuanBarang={s.idSatuanBarang}
nama={s.namaSatuanBarang ?? ""}
/>
</div>
</td>
</tr>
))}
{rows.length === 0 && (
<tr>
<td
colSpan={3}
className="px-3 py-10 text-center text-muted-foreground"
>
{page > 1 ? "Halaman ini kosong" : "Tidak ada satuan"}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<Pagination
page={page}
hasNext={hasNext}
hrefPage={(p) => halamanHref("/master/satuan-barang", p, search)}
/>
</>
);
}

@ -0,0 +1,82 @@
"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 { hapusSatuanBarang, type HapusSatuanBarangState } from "../actions";
type HapusSatuanBarangButtonProps = {
idSatuanBarang: number;
nama: string;
};
const initial: HapusSatuanBarangState = { ok: false };
export function HapusSatuanBarangButton({
idSatuanBarang,
nama,
}: HapusSatuanBarangButtonProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: HapusSatuanBarangState, formData: FormData) => {
const result = await hapusSatuanBarang(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 satuan"
/>
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
Hapus {nama || `satuan ${idSatuanBarang}`}?
</AlertDialogTitle>
<AlertDialogDescription>
Data satuan {idSatuanBarang} akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction}>
<input type="hidden" name="idSatuanBarang" value={idSatuanBarang} />
{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>
);
}

@ -0,0 +1,96 @@
"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 type { SatuanBarang } from "@/data/satuan-barang";
import { simpanSatuanBarang, type SimpanSatuanBarangState } from "../actions";
type SatuanBarangFormProps = {
satuan?: SatuanBarang;
trigger: React.ReactElement;
};
const initial: SimpanSatuanBarangState = { ok: false };
export function SatuanBarangForm({ satuan, trigger }: SatuanBarangFormProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: SimpanSatuanBarangState, formData: FormData) => {
const result = await simpanSatuanBarang(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
initial,
);
const errors = state.fieldErrors?.namaSatuanBarang;
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={trigger} />
<SheetContent>
<SheetHeader>
<SheetTitle>
{satuan ? `Edit ${satuan.namaSatuanBarang}` : "Tambah Satuan"}
</SheetTitle>
<SheetDescription>
{satuan
? `ID: ${satuan.idSatuanBarang}`
: "Isi nama satuan barang baru"}
</SheetDescription>
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{satuan && (
<input
type="hidden"
name="idSatuanBarang"
value={satuan.idSatuanBarang}
/>
)}
<Field data-invalid={!!errors}>
<FieldLabel htmlFor="namaSatuanBarang">Nama Satuan *</FieldLabel>
<Input
id="namaSatuanBarang"
name="namaSatuanBarang"
defaultValue={
state.values?.namaSatuanBarang ?? satuan?.namaSatuanBarang ?? ""
}
aria-invalid={!!errors}
required
/>
<FieldError errors={errors?.map((message) => ({ message }))} />
</Field>
{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>
);
}

@ -0,0 +1,121 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getCurrentUser } from "@/data/auth";
import {
countPemakaianSatuanBarang,
deleteSatuanBarang,
insertSatuanBarang,
updateSatuanBarang,
type SatuanBarangInput,
} from "@/data/satuan-barang";
export type SimpanSatuanBarangState = {
ok: boolean;
error?: string;
fieldErrors?: { namaSatuanBarang?: string[] };
values?: { namaSatuanBarang?: string };
idSatuanBarang?: number;
};
export type HapusSatuanBarangState = { ok: boolean; error?: string };
const satuanBarangSchema = z.object({
namaSatuanBarang: z
.string()
.trim()
.min(1, "Nama satuan wajib diisi")
.max(30, "Maksimal 30 karakter"),
});
export async function simpanSatuanBarang(
_prevState: SimpanSatuanBarangState,
formData: FormData,
): Promise<SimpanSatuanBarangState> {
const user = await getCurrentUser();
const values = {
namaSatuanBarang: String(formData.get("namaSatuanBarang") ?? ""),
};
const parsed = satuanBarangSchema.safeParse(values);
if (!parsed.success) {
return {
ok: false,
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values,
};
}
const input: SatuanBarangInput = parsed.data;
const idRaw = String(formData.get("idSatuanBarang") ?? "");
const id = idRaw ? Number.parseInt(idRaw, 10) : null;
if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) {
return { ok: false, error: "ID satuan tidak valid", values };
}
try {
if (id !== null) {
const updated = await updateSatuanBarang(id, input);
if (!updated) {
return { ok: false, error: "Satuan tidak ditemukan", values };
}
revalidatePath("/master/satuan-barang");
return { ok: true, idSatuanBarang: id };
}
const idBaru = await insertSatuanBarang(input);
revalidatePath("/master/satuan-barang");
return { ok: true, idSatuanBarang: idBaru };
} catch (error) {
console.error(
"[simpanSatuanBarang] gagal",
{
userId: user.id,
idSatuanBarang: id ?? "(baru)",
nama: input.namaSatuanBarang,
},
error,
);
return {
ok: false,
error: "Gagal menyimpan satuan, coba simpan ulang",
values,
};
}
}
export async function hapusSatuanBarang(
_prevState: HapusSatuanBarangState,
formData: FormData,
): Promise<HapusSatuanBarangState> {
const user = await getCurrentUser();
const id = Number.parseInt(String(formData.get("idSatuanBarang") ?? ""), 10);
if (!Number.isSafeInteger(id) || id <= 0) {
return { ok: false, error: "ID satuan tidak ada" };
}
try {
const dipakai = await countPemakaianSatuanBarang(id);
if (dipakai > 0) {
return {
ok: false,
error: `Satuan masih dipakai ${dipakai} barang, tidak bisa dihapus`,
};
}
const deleted = await deleteSatuanBarang(id);
if (!deleted) {
return { ok: false, error: "Satuan tidak ditemukan" };
}
revalidatePath("/master/satuan-barang");
return { ok: true };
} catch (error) {
console.error("[hapusSatuanBarang] gagal", { userId: user.id, id }, error);
return {
ok: false,
error: "Gagal menghapus satuan, coba hapus ulang",
};
}
}

@ -0,0 +1,40 @@
import { Suspense } from "react";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CariMaster } from "@/components/cari-master";
import { DaftarSatuanBarang } from "./_components/daftar-satuan-barang";
import { SatuanBarangForm } from "./_components/satuan-barang-form";
export default function SatuanBarangPage({
searchParams,
}: {
searchParams: Promise<{ search?: string; page?: string }>;
}) {
return (
<div className="p-4">
<div className="flex items-center justify-between">
<h1 className="text-lg font-semibold">Data Satuan Barang</h1>
<div className="flex items-center gap-2">
<Suspense fallback={null}>
<CariMaster
basePath="/master/satuan-barang"
placeholder="Cari satuan..."
/>
</Suspense>
<SatuanBarangForm
trigger={
<Button>
Tambah
<PlusIcon />
</Button>
}
/>
</div>
</div>
<Suspense fallback={<p className="mt-4 text-sm">Memuat...</p>}>
<DaftarSatuanBarang searchParams={searchParams} />
</Suspense>
</div>
);
}

@ -6,7 +6,12 @@ import { SearchIcon } from "lucide-react";
import { Input } from "@/components/ui/input";
export function CariPelanggan() {
type CariMasterProps = {
basePath: string;
placeholder?: string;
};
export function CariMaster({ basePath, placeholder }: CariMasterProps) {
const router = useRouter();
const searchParams = useSearchParams();
const [nilai, setNilai] = useState(searchParams.get("search") ?? "");
@ -17,19 +22,19 @@ export function CariPelanggan() {
const timer = setTimeout(() => {
const url = nilai
? `/master/pelanggan?search=${encodeURIComponent(nilai)}`
: "/master/pelanggan";
? `${basePath}?search=${encodeURIComponent(nilai)}`
: basePath;
router.replace(url);
}, 300);
return () => clearTimeout(timer);
}, [nilai, searchParams, router]);
}, [nilai, searchParams, router, basePath]);
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..."
placeholder={placeholder}
value={nilai}
onChange={(e) => setNilai(e.target.value)}
/>

@ -3,6 +3,8 @@ import "server-only";
import { asc, desc, like, or, eq } from "drizzle-orm";
import { amigoDb } from "@/db/amigo";
import { pelanggan } from "@/db/amigo/schema";
import { PER_PAGE } from "@/lib/pagination";
import { escapeLike } from "@/lib/utils";
export type Pelanggan = {
kdCustomer: string;
@ -25,8 +27,6 @@ export type PelangganPage = {
hasNext: boolean;
};
const PER_PAGE = 25;
// kdCustomer dibuat sistem, nilaiBon diisi transaksi BON; keduanya bukan isian form.
export type PelangganInput = Omit<Pelanggan, "kdCustomer" | "nilaiBon">;
@ -38,11 +38,6 @@ function todayWib(): string {
);
}
// `\`, `%`, 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;

@ -0,0 +1,93 @@
import "server-only";
import { asc, count, eq, like } from "drizzle-orm";
import { amigoDb } from "@/db/amigo";
import { no_barang, satuan_barang } from "@/db/amigo/schema";
import { PER_PAGE } from "@/lib/pagination";
import { escapeLike } from "@/lib/utils";
export type SatuanBarang = {
idSatuanBarang: number;
namaSatuanBarang: string | null;
};
export type SatuanBarangPage = {
rows: SatuanBarang[];
hasNext: boolean;
};
// Kolom DB nullable, tapi isian form wajib string.
export type SatuanBarangInput = {
namaSatuanBarang: NonNullable<SatuanBarang["namaSatuanBarang"]>;
};
export async function insertSatuanBarang(
input: SatuanBarangInput,
): Promise<number> {
const [res] = await amigoDb.insert(satuan_barang).values({
namaSatuanBarang: input.namaSatuanBarang,
});
return res.insertId;
}
export async function updateSatuanBarang(
idSatuanBarang: number,
input: SatuanBarangInput,
): Promise<boolean> {
const [res] = await amigoDb
.update(satuan_barang)
.set({ namaSatuanBarang: input.namaSatuanBarang })
.where(eq(satuan_barang.idSatuanBarang, idSatuanBarang));
return res.affectedRows > 0;
}
// `no_barang.satuan` menyimpan idSatuanBarang sebagai varchar.
export async function countPemakaianSatuanBarang(
idSatuanBarang: number,
): Promise<number> {
const [row] = await amigoDb
.select({ total: count() })
.from(no_barang)
.where(eq(no_barang.satuan, String(idSatuanBarang)));
return row?.total ?? 0;
}
export async function deleteSatuanBarang(
idSatuanBarang: number,
): Promise<boolean> {
const [res] = await amigoDb
.delete(satuan_barang)
.where(eq(satuan_barang.idSatuanBarang, idSatuanBarang));
return res.affectedRows > 0;
}
export async function listSatuanBarang(
search?: string,
page = 1,
): Promise<SatuanBarangPage> {
const q = search?.trim();
const pattern = q ? `%${escapeLike(q)}%` : "";
const rows = await amigoDb
.select({
idSatuanBarang: satuan_barang.idSatuanBarang,
namaSatuanBarang: satuan_barang.namaSatuanBarang,
})
.from(satuan_barang)
.where(q ? like(satuan_barang.namaSatuanBarang, pattern) : undefined)
.orderBy(
asc(satuan_barang.namaSatuanBarang),
asc(satuan_barang.idSatuanBarang),
)
.limit(PER_PAGE + 1)
.offset((page - 1) * PER_PAGE);
return {
rows: rows.slice(0, PER_PAGE),
hasNext: rows.length > PER_PAGE,
};
}

@ -0,0 +1,19 @@
export const PER_PAGE = 25;
// Batas atas mencegah offset melebihi BIGINT MariaDB (query error).
export function parsePage(pageParam?: string): number {
const parsed = Number.parseInt(pageParam ?? "1", 10);
return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : 1;
}
export function halamanHref(
basePath: string,
page: number,
search?: string,
): string {
const params = new URLSearchParams();
if (search) params.set("search", search);
if (page > 1) params.set("page", String(page));
const query = params.toString();
return query ? `${basePath}?${query}` : basePath;
}

@ -1 +1,6 @@
export { cn } from "cn"
export { cn } from "cn";
// `\`, `%`, dan `_` adalah karakter khusus LIKE (escape default MariaDB `\`).
export function escapeLike(value: string): string {
return value.replace(/[\\%_]/g, "\\$&");
}

Loading…
Cancel
Save