Add master rak feature
parent
770ecbd204
commit
5c89d5714f
@ -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…
Reference in New Issue