Add master rak feature

main
Deardo Satria 22 hours ago
parent 770ecbd204
commit 5c89d5714f

@ -22,6 +22,7 @@ export const navItems: NavItem[] = [
items: [
{ title: "Satuan Barang", href: "/master/satuan-barang" },
{ title: "Data Pelanggan", href: "/master/pelanggan" },
{ title: "Rak", href: "/master/rak" },
],
},
];

@ -0,0 +1,85 @@
import { PencilIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Pagination } from "@/components/pagination";
import { getCurrentUser } from "@/data/auth";
import { listRak } from "@/data/rak";
import { halamanHref, parsePage } from "@/lib/pagination";
import { HapusRakButton } from "./hapus-rak-button";
import { RakForm } from "./rak-form";
export async function DaftarRak({
searchParams,
}: {
searchParams: Promise<{ search?: string; page?: string }>;
}) {
await getCurrentUser();
const { search, page: pageParam } = await searchParams;
const page = parsePage(pageParam);
const { rows, hasNext } = await listRak(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 Rak</th>
<th className="px-3 py-2.5 text-right font-medium">Aksi</th>
</tr>
</thead>
<tbody className="divide-y">
{rows.map((r) => (
<tr
key={r.idRak}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">
{r.idRak}
</td>
<td className="px-3 py-2 font-medium">{r.namaRak}</td>
<td className="px-3 py-2">
<div className="flex justify-end gap-1">
<RakForm
rak={r}
trigger={
<Button
variant="ghost"
size="icon-sm"
title="Edit rak"
>
<PencilIcon />
<span className="sr-only">Edit</span>
</Button>
}
/>
<HapusRakButton idRak={r.idRak} nama={r.namaRak ?? ""} />
</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 rak"}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<Pagination
page={page}
hasNext={hasNext}
hrefPage={(p) => halamanHref("/master/rak", p, search)}
/>
</>
);
}

@ -0,0 +1,77 @@
"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 { hapusRak, type HapusRakState } from "../actions";
type HapusRakButtonProps = {
idRak: number;
nama: string;
};
const initial: HapusRakState = { ok: false };
export function HapusRakButton({ idRak, nama }: HapusRakButtonProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: HapusRakState, formData: FormData) => {
const result = await hapusRak(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 rak"
/>
}
>
<Trash2Icon />
<span className="sr-only">Hapus</span>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Hapus {nama || `rak ${idRak}`}?</AlertDialogTitle>
<AlertDialogDescription>
Data rak {idRak} akan dihapus permanen.
</AlertDialogDescription>
</AlertDialogHeader>
<form action={formAction}>
<input type="hidden" name="idRak" value={idRak} />
{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,84 @@
"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 { Rak } from "@/data/rak";
import { simpanRak, type SimpanRakState } from "../actions";
type RakFormProps = {
rak?: Rak;
trigger: React.ReactElement;
};
const initial: SimpanRakState = { ok: false };
export function RakForm({ rak, trigger }: RakFormProps) {
const [open, setOpen] = useState(false);
const [state, formAction, pending] = useActionState(
async (prevState: SimpanRakState, formData: FormData) => {
const result = await simpanRak(prevState, formData);
if (result.ok) setOpen(false);
return result;
},
initial,
);
const errors = state.fieldErrors?.namaRak;
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger render={trigger} />
<SheetContent>
<SheetHeader>
<SheetTitle>{rak ? `Edit ${rak.namaRak}` : "Tambah Rak"}</SheetTitle>
<SheetDescription>
{rak ? `ID: ${rak.idRak}` : "Isi nama rak baru"}
</SheetDescription>
</SheetHeader>
<form
action={formAction}
className="flex flex-col gap-4 overflow-y-auto p-4"
>
{rak && <input type="hidden" name="idRak" value={rak.idRak} />}
<Field data-invalid={!!errors}>
<FieldLabel htmlFor="namaRak">Nama Rak *</FieldLabel>
<Input
id="namaRak"
name="namaRak"
defaultValue={state.values?.namaRak ?? rak?.namaRak ?? ""}
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,107 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { getCurrentUser } from "@/data/auth";
import { deleteRak, insertRak, updateRak, type RakInput } from "@/data/rak";
export type SimpanRakState = {
ok: boolean;
error?: string;
fieldErrors?: { namaRak?: string[] };
values?: { namaRak?: string };
idRak?: number;
};
export type HapusRakState = { ok: boolean; error?: string };
const rakSchema = z.object({
namaRak: z
.string()
.trim()
.min(1, "Nama rak wajib diisi")
.max(30, "Maksimal 30 karakter"),
});
export async function simpanRak(
_prevState: SimpanRakState,
formData: FormData,
): Promise<SimpanRakState> {
const user = await getCurrentUser();
const values = {
namaRak: String(formData.get("namaRak") ?? ""),
};
const parsed = rakSchema.safeParse(values);
if (!parsed.success) {
return {
ok: false,
fieldErrors: z.flattenError(parsed.error).fieldErrors,
values,
};
}
const input: RakInput = parsed.data;
const idRaw = String(formData.get("idRak") ?? "");
const id = idRaw ? Number.parseInt(idRaw, 10) : null;
if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) {
return { ok: false, error: "ID rak tidak valid", values };
}
try {
if (id !== null) {
const updated = await updateRak(id, input);
if (!updated) {
return { ok: false, error: "Rak tidak ditemukan", values };
}
revalidatePath("/master/rak");
return { ok: true, idRak: id };
}
const idBaru = await insertRak(input);
revalidatePath("/master/rak");
return { ok: true, idRak: idBaru };
} catch (error) {
console.error(
"[simpanRak] gagal",
{
userId: user.id,
idRak: id ?? "(baru)",
nama: input.namaRak,
},
error,
);
return {
ok: false,
error: "Gagal menyimpan rak, coba simpan ulang",
values,
};
}
}
export async function hapusRak(
_prevState: HapusRakState,
formData: FormData,
): Promise<HapusRakState> {
const user = await getCurrentUser();
const id = Number.parseInt(String(formData.get("idRak") ?? ""), 10);
if (!Number.isSafeInteger(id) || id <= 0) {
return { ok: false, error: "ID rak tidak ada" };
}
try {
const deleted = await deleteRak(id);
if (!deleted) {
return { ok: false, error: "Rak tidak ditemukan" };
}
revalidatePath("/master/rak");
return { ok: true };
} catch (error) {
console.error("[hapusRak] gagal", { userId: user.id, id }, error);
return {
ok: false,
error: "Gagal menghapus rak, coba hapus ulang",
};
}
}

@ -0,0 +1,37 @@
import { Suspense } from "react";
import { PlusIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { CariMaster } from "@/components/cari-master";
import { DaftarRak } from "./_components/daftar-rak";
import { RakForm } from "./_components/rak-form";
export default function RakPage({
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 Rak</h1>
<div className="flex items-center gap-2">
<Suspense fallback={null}>
<CariMaster basePath="/master/rak" placeholder="Cari rak..." />
</Suspense>
<RakForm
trigger={
<Button>
Tambah
<PlusIcon />
</Button>
}
/>
</div>
</div>
<Suspense fallback={<p className="mt-4 text-sm">Memuat...</p>}>
<DaftarRak searchParams={searchParams} />
</Suspense>
</div>
);
}

@ -0,0 +1,67 @@
import "server-only";
import { asc, eq, like } from "drizzle-orm";
import { amigoDb } from "@/db/amigo";
import { PER_PAGE } from "@/lib/pagination";
import { escapeLike } from "@/lib/utils";
import { rak } from "@/db/amigo/schema";
export type Rak = {
idRak: number;
namaRak: string | null;
};
export type RakPage = {
rows: Rak[];
hasNext: boolean;
};
export type RakInput = {
namaRak: NonNullable<Rak["namaRak"]>;
};
export async function insertRak(input: RakInput): Promise<number> {
const [res] = await amigoDb.insert(rak).values({
namaRak: input.namaRak,
});
return res.insertId;
}
export async function updateRak(
idRak: number,
input: RakInput,
): Promise<boolean> {
const [res] = await amigoDb
.update(rak)
.set({ namaRak: input.namaRak })
.where(eq(rak.idRak, idRak));
return res.affectedRows > 0;
}
export async function deleteRak(idRak: number): Promise<boolean> {
const [res] = await amigoDb.delete(rak).where(eq(rak.idRak, idRak));
return res.affectedRows > 0;
}
export async function listRak(search?: string, page = 1): Promise<RakPage> {
const q = search?.trim();
const pattern = q ? `%${escapeLike(q)}%` : "";
const rows = await amigoDb
.select({
idRak: rak.idRak,
namaRak: rak.namaRak,
})
.from(rak)
.where(q ? like(rak.namaRak, pattern) : undefined)
.orderBy(asc(rak.namaRak), asc(rak.idRak))
.limit(PER_PAGE + 1)
.offset((page - 1) * PER_PAGE);
return {
rows: rows.slice(0, PER_PAGE),
hasNext: rows.length > PER_PAGE,
};
}
Loading…
Cancel
Save