summaryrefslogtreecommitdiff
path: root/lib/general-check-list
diff options
context:
space:
mode:
Diffstat (limited to 'lib/general-check-list')
-rw-r--r--lib/general-check-list/repository.ts49
-rw-r--r--lib/general-check-list/service.ts245
-rw-r--r--lib/general-check-list/table/add-check-list-dialog.tsx112
-rw-r--r--lib/general-check-list/table/delete-check-lists-dialog.tsx106
-rw-r--r--lib/general-check-list/table/general-check-list-table.tsx63
-rw-r--r--lib/general-check-list/table/general-check-table-columns.tsx138
-rw-r--r--lib/general-check-list/table/update-check-list-sheet.tsx162
-rw-r--r--lib/general-check-list/validation.ts30
8 files changed, 905 insertions, 0 deletions
diff --git a/lib/general-check-list/repository.ts b/lib/general-check-list/repository.ts
new file mode 100644
index 00000000..100975ab
--- /dev/null
+++ b/lib/general-check-list/repository.ts
@@ -0,0 +1,49 @@
+import { generalEvaluations } from "@/db/schema";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+
+export async function selectGeneralCheckLists(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return await tx
+ .select()
+ .from(generalEvaluations)
+ .where(where)
+ .orderBy(...(orderBy ?? [asc(generalEvaluations.createdAt)]))
+ .offset(offset ?? 0)
+ .limit(limit ?? 10);
+}
+
+export async function countGeneralCheckList(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const result = await tx
+ .select({ count: count() })
+ .from(generalEvaluations)
+ .where(where);
+
+ return result[0]?.count ?? 0;
+} \ No newline at end of file
diff --git a/lib/general-check-list/service.ts b/lib/general-check-list/service.ts
new file mode 100644
index 00000000..fde756ea
--- /dev/null
+++ b/lib/general-check-list/service.ts
@@ -0,0 +1,245 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import db from "@/db/db";
+
+import { filterColumns } from "@/lib/filter-columns";
+
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq } from "drizzle-orm";
+import { generalEvaluations} from "@/db/schema";
+import { GetGeneralEvaluationsSchema } from "./validation";
+import { selectGeneralCheckLists , countGeneralCheckList} from "./repository";
+
+export async function getGeneralCheckList(input: GetGeneralEvaluationsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+
+ // 고급 필터 처리 - 테이블의 DataTableFilterList에서 오는 필터
+ const advancedFilters = input.filters || [];
+ const advancedJoinOperator = input.joinOperator || "and";
+
+
+ // 고급 필터 조건 생성
+ let advancedWhere;
+ if (advancedFilters.length > 0) {
+ advancedWhere = filterColumns({
+ table: generalEvaluations,
+ filters: advancedFilters,
+ joinOperator: advancedJoinOperator,
+ });
+ }
+
+ // 전역 검색 조건
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(generalEvaluations.category, s),
+ ilike(generalEvaluations.inspectionItem, s),
+ ilike(generalEvaluations.remarks, s),
+ );
+ }
+
+ // 모든 조건 결합
+ let whereConditions = [];
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ // 조건이 있을 때만 and() 사용
+ const finalWhere = whereConditions.length > 0
+ ? and(...whereConditions)
+ : undefined;
+
+
+ // 정렬 조건 - 안전하게 처리
+ const orderBy =
+ input.sort && input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(generalEvaluations[item.id])
+ : asc(generalEvaluations[item.id])
+ )
+ : [desc(generalEvaluations.updatedAt)]
+
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectGeneralCheckLists(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countGeneralCheckList(tx, finalWhere);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ console.error("getRfqs 에러:", err);
+
+ // 에러 세부 정보 더 자세히 로깅
+ if (err instanceof Error) {
+ console.error("에러 메시지:", err.message);
+ console.error("에러 스택:", err.stack);
+
+ if ('code' in err) {
+ console.error("SQL 에러 코드:", (err as any).code);
+ }
+ }
+
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Types
+// ─────────────────────────────────────────────────────────────────────────────
+export type GeneralEvaluationInput = {
+ category: string;
+ inspectionItem: string;
+ remarks?: string | null;
+ isActive?: boolean;
+ };
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────────────
+ async function generateSerialNumber(tx: typeof db, category: string) {
+ const prefix = `GE-${category}-`;
+
+ // 카테고리 내에서 가장 최근 시리얼 찾아서 +1
+ const latest = await tx
+ .select({ serialNumber: generalEvaluations.serialNumber })
+ .from(generalEvaluations)
+ .where(eq(generalEvaluations.category, category))
+ .orderBy(desc(generalEvaluations.serialNumber))
+ .limit(1);
+
+ let nextSeq = 1;
+ if (latest.length) {
+ const parts = latest[0].serialNumber.split("-");
+ const last = parts[parts.length - 1];
+ const num = parseInt(last, 10);
+ if (!isNaN(num)) nextSeq = num + 1;
+ }
+
+ const seqStr = nextSeq.toString().padStart(3, "0");
+ return `${prefix}${seqStr}`;
+ }
+ // ─────────────────────────────────────────────────────────────────────────────
+ // CRUD Actions
+ // ─────────────────────────────────────────────────────────────────────────────
+ export async function createGeneralEvaluation(input: GeneralEvaluationInput) {
+ return db.transaction(async (tx) => {
+ try {
+ const serialNumber = await generateSerialNumber(tx, input.category);
+
+ const [created] = await tx
+ .insert(generalEvaluations)
+ .values({
+ serialNumber,
+ category: input.category,
+ inspectionItem: input.inspectionItem,
+ remarks: input.remarks ?? null,
+ isActive: input.isActive ?? true,
+ })
+ .returning();
+
+ return { success: true, data: created, message: "체크리스트가 추가되었습니다." };
+ } catch (err) {
+ console.error("createGeneralEvaluation error", err);
+ return { success: false, message: "추가 중 오류가 발생했습니다." };
+ }
+ });
+ }
+
+ export async function updateGeneralEvaluation(id: number, fields: Partial<GeneralEvaluationInput>) {
+ try {
+ const [updated] = await db
+ .update(generalEvaluations)
+ .set({ ...fields, updatedAt: sql`now()` })
+ .where(eq(generalEvaluations.id, id))
+ .returning();
+
+ return { success: true, data: updated, message: "수정되었습니다." };
+ } catch (err) {
+ console.error("updateGeneralEvaluation error", err);
+ return { success: false, message: "수정 중 오류가 발생했습니다." };
+ }
+ }
+
+ export async function deleteGeneralEvaluations(ids: number[]) {
+ if (ids.length === 0) return { success: false, message: "삭제할 항목이 없습니다." };
+ try {
+ await db.delete(generalEvaluations).where(inArray(generalEvaluations.id, ids));
+ return { success: true, message: `${ids.length}개의 체크리스트가 삭제되었습니다.` };
+ } catch (err) {
+ console.error("deleteGeneralEvaluations error", err);
+ return { success: false, message: "삭제 중 오류가 발생했습니다." };
+ }
+ }
+
+ // ─────────────────────────────────────────────────────────────────────────────
+ // Pagination Search (기존 getGeneralCheckList → getGeneralEvaluations)
+ // ─────────────────────────────────────────────────────────────────────────────
+ export async function getGeneralEvaluations(input: GetGeneralEvaluationsSchema) {
+ const offset = (input.page - 1) * input.perPage;
+
+ // 고급 필터 처리
+ const advFilters = input.filters ?? [];
+ const advOperator = input.joinOperator ?? "and";
+ const advWhere = advFilters.length
+ ? filterColumns({ table: generalEvaluations, filters: advFilters, joinOperator: advOperator })
+ : undefined;
+
+ // 전역 검색
+ const globalWhere = input.search
+ ? or(
+ ilike(generalEvaluations.serialNumber, `%${input.search}%`),
+ ilike(generalEvaluations.category, `%${input.search}%`),
+ ilike(generalEvaluations.inspectionItem, `%${input.search}%`),
+ ilike(generalEvaluations.remarks, `%${input.search}%`)
+ )
+ : undefined;
+
+ const whereAll = [advWhere, globalWhere].filter(Boolean);
+ const finalWhere = whereAll.length ? and(...whereAll) : undefined;
+
+ // 정렬
+ const orderBy =
+ input.sort && input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(generalEvaluations[item.id])
+ : asc(generalEvaluations[item.id])
+ )
+ : [desc(generalEvaluations.updatedAt)]
+
+
+ // 트랜잭션
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await tx
+ .select()
+ .from(generalEvaluations)
+ .where(finalWhere ?? undefined)
+ .orderBy(...orderBy)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const [{ count }] = await tx
+ .select({ count: sql<number>`count(*)` })
+ .from(generalEvaluations)
+ .where(finalWhere ?? undefined);
+
+ return { data, total: count };
+ });
+
+
+ return { data, pageCount: Math.ceil(total / input.perPage) };
+ }
+ \ No newline at end of file
diff --git a/lib/general-check-list/table/add-check-list-dialog.tsx b/lib/general-check-list/table/add-check-list-dialog.tsx
new file mode 100644
index 00000000..5721bd59
--- /dev/null
+++ b/lib/general-check-list/table/add-check-list-dialog.tsx
@@ -0,0 +1,112 @@
+"use client";
+import * as React from "react";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
+import { Plus } from "lucide-react";
+import { toast } from "sonner";
+import { createGeneralEvaluation } from "@/lib/general-check-list/service";
+import { useRouter } from "next/navigation";
+
+const schema = z.object({
+ category: z.string().min(1, "카테고리를 입력하세요"),
+ inspectionItem: z.string().min(1, "점검 항목을 입력하세요"),
+ remarks: z.string().optional(),
+});
+
+type FormValues = z.infer<typeof schema>;
+
+export function CreateEvaluationDialog({ onSuccess }: { onSuccess?: () => void }) {
+ const [open, setOpen] = React.useState(false);
+ const [pending, setPending] = React.useState(false);
+ const router = useRouter(); // ⬅️
+
+ const form = useForm<FormValues>({
+ resolver: zodResolver(schema),
+ defaultValues: { category: "", inspectionItem: "", remarks: "" },
+ });
+
+ async function onSubmit(values: FormValues) {
+ setPending(true);
+ const res = await createGeneralEvaluation(values);
+ if (res.success) {
+ toast.success(res.message);
+ router.refresh(); // ❷ 새로고침
+
+ onSuccess?.();
+ setOpen(false);
+ form.reset();
+ } else {
+ toast.error(res.message);
+ }
+ setPending(false);
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={(v) => !pending && setOpen(v)}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Plus className="size-4" /> 새 항목
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[480px]">
+ <DialogHeader>
+ <DialogTitle>새 정기평가 체크리스트</DialogTitle>
+ <DialogDescription>점검 항목을 추가합니다.</DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>카테고리</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 안전" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="inspectionItem"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>점검 항목</FormLabel>
+ <FormControl>
+ <Input placeholder="예: 안전모 착용 여부" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고 (선택)</FormLabel>
+ <FormControl>
+ <Input placeholder="메모" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <DialogFooter>
+ <Button type="submit" disabled={pending}>
+ {pending ? "저장중..." : "저장"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+} \ No newline at end of file
diff --git a/lib/general-check-list/table/delete-check-lists-dialog.tsx b/lib/general-check-list/table/delete-check-lists-dialog.tsx
new file mode 100644
index 00000000..b4b4d648
--- /dev/null
+++ b/lib/general-check-list/table/delete-check-lists-dialog.tsx
@@ -0,0 +1,106 @@
+"use client";
+import * as React from "react";
+import { Button } from "@/components/ui/button";
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from "@/components/ui/dialog";
+import { Drawer, DrawerTrigger, DrawerContent, DrawerHeader, DrawerTitle, DrawerDescription, DrawerFooter, DrawerClose } from "@/components/ui/drawer";
+import { Trash, Loader } from "lucide-react";
+import { useMediaQuery } from "@/hooks/use-media-query";
+import { deleteGeneralEvaluations } from "@/lib/general-check-list/service";
+import { toast } from "sonner";
+import { useRouter } from "next/navigation";
+
+export function DeleteEvaluationsDialog({
+ evaluations,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: {
+ evaluations: { id: number; serialNumber: string }[];
+ showTrigger?: boolean;
+ onSuccess?: () => void;
+ open?: boolean;
+ onOpenChange?: (v: boolean) => void;
+}) {
+ const [pending, startTransition] = React.useTransition();
+ const isDesktop = useMediaQuery("(min-width:640px)");
+ const router = useRouter();
+
+ const deleteText = evaluations.length === 1 ? "항목" : "항목들";
+
+ const handleDelete = () => {
+ startTransition(async () => {
+ const res = await deleteGeneralEvaluations(evaluations.map((e) => e.id));
+ if (res.success) {
+ toast.success(res.message);
+ router.refresh();
+ props.onOpenChange?.(false);
+ onSuccess?.();
+ } else {
+ toast.error(res.message);
+ }
+ });
+ };
+
+ const Content = (
+ <>
+ <DialogHeader>
+ <DialogTitle>삭제 확인</DialogTitle>
+ <DialogDescription>
+ 선택된 {evaluations.length}개의 {deleteText}을(를) 삭제합니다. 이 작업은 되돌릴 수 없습니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button variant="destructive" onClick={handleDelete} disabled={pending}>
+ {pending && <Loader className="mr-2 size-4 animate-spin" />} 삭제
+ </Button>
+ </DialogFooter>
+ </>
+ );
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger && (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="size-4 mr-2" /> 삭제({evaluations.length})
+ </Button>
+ </DialogTrigger>
+ )}
+ <DialogContent>{Content}</DialogContent>
+ </Dialog>
+ );
+ }
+
+ // Mobile Drawer
+ return (
+ <Drawer {...props}>
+ {showTrigger && (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="size-4 mr-2" /> 삭제({evaluations.length})
+ </Button>
+ </DrawerTrigger>
+ )}
+ <DrawerContent className="p-4 space-y-4">
+ <DrawerHeader>
+ <DrawerTitle>삭제 확인</DrawerTitle>
+ </DrawerHeader>
+ <DrawerDescription>
+ 선택된 {evaluations.length}개의 {deleteText}을(를) 삭제합니다. 이 작업은 되돌릴 수 없습니다.
+ </DrawerDescription>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button variant="destructive" onClick={handleDelete} disabled={pending}>
+ {pending && <Loader className="mr-2 size-4 animate-spin" />} 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ );
+}
diff --git a/lib/general-check-list/table/general-check-list-table.tsx b/lib/general-check-list/table/general-check-list-table.tsx
new file mode 100644
index 00000000..34b39830
--- /dev/null
+++ b/lib/general-check-list/table/general-check-list-table.tsx
@@ -0,0 +1,63 @@
+"use client";
+import * as React from "react";
+import { useDataTable } from "@/hooks/use-data-table";
+import { DataTable } from "@/components/data-table/data-table";
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
+import type { DataTableFilterField, DataTableAdvancedFilterField } from "@/types/table";
+import { getGeneralEvaluationColumns } from "./general-check-table-columns";
+import { CreateEvaluationDialog } from "./add-check-list-dialog";
+import { DeleteEvaluationsDialog } from "./delete-check-lists-dialog";
+import { getGeneralEvaluations } from "@/lib/general-check-list/service";
+
+
+interface EsgEvaluationsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getGeneralEvaluations>>,
+ ]
+ >
+ }
+
+export function GeneralEvaluationsTable({ promises }: EsgEvaluationsTableProps) {
+
+ const [{ data, pageCount }]= React.use(promises);
+ const columns = React.useMemo(() => getGeneralEvaluationColumns(), []);
+
+ // Filters (간단 예시)
+ const filterFields: DataTableFilterField<any>[] = [
+ { id: "serialNumber", label: "시리얼", placeholder: "시리얼 검색" },
+ { id: "category", label: "카테고리", placeholder: "카테고리" },
+ ];
+ const advFilterFields: DataTableAdvancedFilterField<any>[] = [
+ { id: "inspectionItem", label: "점검 항목", type: "text" },
+ { id: "remarks", label: "비고", type: "text" },
+ ];
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enableAdvancedFilter: true,
+ initialState: { sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] } },
+ getRowId: (row) => row.id.toString(),
+ clearOnDefault: true,
+ shallow: false,
+ });
+
+ // Delete selected
+ const selectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
+
+ return (
+ <div className="space-y-6">
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar table={table} filterFields={advFilterFields} shallow={false}>
+ <CreateEvaluationDialog />
+ {selectedRows.length > 0 && (
+ <DeleteEvaluationsDialog evaluations={selectedRows} />
+ )}
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </div>
+ );
+}
diff --git a/lib/general-check-list/table/general-check-table-columns.tsx b/lib/general-check-list/table/general-check-table-columns.tsx
new file mode 100644
index 00000000..c764686d
--- /dev/null
+++ b/lib/general-check-list/table/general-check-table-columns.tsx
@@ -0,0 +1,138 @@
+"use client";
+import * as React from "react";
+import { type ColumnDef } from "@tanstack/react-table";
+import { Checkbox } from "@/components/ui/checkbox";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Ellipsis, Pencil, Trash } from "lucide-react";
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
+import { DeleteEvaluationsDialog } from "./delete-check-lists-dialog";
+import { EditEvaluationSheet } from "./update-check-list-sheet";
+
+
+export interface GeneralEvaluationRow {
+ id: number;
+ serialNumber: string;
+ category: string;
+ inspectionItem: string;
+ remarks: string | null;
+ isActive: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+}
+
+export function getGeneralEvaluationColumns(): ColumnDef<GeneralEvaluationRow>[] {
+ return [
+ // Checkbox
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={table.getIsAllPageRowsSelected() || (table.getIsSomePageRowsSelected() && "indeterminate")}
+ onCheckedChange={(v) => table.toggleAllPageRowsSelected(!!v)}
+ aria-label="select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(v) => row.toggleSelected(!!v)}
+ aria-label="select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ // ░░░ Serial Number ░░░
+ {
+ accessorKey: "serialNumber",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="시리얼" />,
+ cell: ({ row }) => row.getValue("serialNumber"),
+ size: 120,
+ },
+
+ // ░░░ Category ░░░
+ {
+ accessorKey: "category",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="카테고리" />,
+ cell: ({ row }) => <Badge>{row.getValue("category")}</Badge>,
+ size: 120,
+ },
+
+ // ░░░ Inspection Item ░░░
+ {
+ accessorKey: "inspectionItem",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="점검 항목" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[300px]" title={row.getValue<string>("inspectionItem")!}>
+ {row.getValue("inspectionItem") as string}
+ </div>
+ ),
+ size: 300,
+ },
+
+ // ░░░ Remarks ░░░
+ {
+ accessorKey: "remarks",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="비고" />,
+ cell: ({ row }) => row.getValue("remarks") ?? <span className="text-muted-foreground">-</span>,
+ size: 200,
+ },
+
+ // ░░░ 활성 ░░░
+ {
+ accessorKey: "isActive",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="활성" />,
+ cell: ({ row }) => (row.getValue("isActive") ? <Badge variant="default">활성</Badge> : <Badge variant="secondary">비활성</Badge>),
+ size: 80,
+ },
+
+ // ░░░ Actions ░░░
+ {
+ id: "actions",
+ enableHiding: false,
+ size: 40,
+ minSize:80,
+ cell: ({ row }) => {
+ const record = row.original;
+ const [openEdit, setOpenEdit] = React.useState(false);
+ const [openDelete, setOpenDelete] = React.useState(false);
+
+ return (
+ <>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-8"
+ onClick={() => setOpenEdit(true)}
+ aria-label="edit"
+ >
+ <Pencil className="size-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-8"
+ onClick={() => setOpenDelete(true)}
+ aria-label="delete"
+ >
+ <Trash className="size-4" />
+ </Button>
+
+ <EditEvaluationSheet open={openEdit} onOpenChange={setOpenEdit} evaluation={record} />
+ <DeleteEvaluationsDialog
+ open={openDelete}
+ onOpenChange={setOpenDelete}
+ evaluations={[record]}
+ showTrigger={false}
+ />
+ </>
+ );
+ },
+ },
+ ];
+}
diff --git a/lib/general-check-list/table/update-check-list-sheet.tsx b/lib/general-check-list/table/update-check-list-sheet.tsx
new file mode 100644
index 00000000..6c845465
--- /dev/null
+++ b/lib/general-check-list/table/update-check-list-sheet.tsx
@@ -0,0 +1,162 @@
+"use client";
+
+import * as React from "react";
+import { z } from "zod";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { toast } from "sonner";
+import {
+ Sheet, // ⬅️ Drawer 대신 Sheet
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetFooter,
+ SheetClose,
+} from "@/components/ui/sheet";
+import { Button } from "@/components/ui/button";
+import {
+ Form, FormField, FormItem, FormLabel,
+ FormControl, FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { updateGeneralEvaluation } from "@/lib/general-check-list/service";
+import { useRouter } from "next/navigation";
+
+const schema = z.object({
+ category: z.string(),
+ inspectionItem: z.string(),
+ remarks: z.string().optional(),
+ isActive: z.boolean().optional(),
+});
+
+type Values = z.infer<typeof schema>;
+
+export function EditEvaluationSheet({
+ open,
+ onOpenChange,
+ evaluation,
+ onSuccess,
+}: {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ evaluation: any;
+ onSuccess?: () => void;
+}) {
+ const [pending, setPending] = React.useState(false);
+ const router = useRouter(); // ⬅️
+
+ const form = useForm<Values>({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ category: evaluation.category,
+ inspectionItem: evaluation.inspectionItem,
+ remarks: evaluation.remarks ?? "",
+ isActive: evaluation.isActive,
+ },
+ });
+
+ async function onSubmit(values: Values) {
+ setPending(true);
+ const res = await updateGeneralEvaluation(evaluation.id, values);
+ setPending(false);
+
+ if (res.success) {
+ toast.success(res.message);
+ router.refresh();
+
+ onSuccess?.();
+ onOpenChange(false);
+ } else {
+ toast.error(res.message);
+ }
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={(v) => !pending && onOpenChange(v)}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-lg">
+ <SheetHeader>
+ <SheetTitle>항목 수정 – {evaluation.serialNumber}</SheetTitle>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-4">
+ {/* 카테고리 */}
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>카테고리</FormLabel>
+ <FormControl>
+ <Input {...field} disabled={pending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 점검 항목 */}
+ <FormField
+ control={form.control}
+ name="inspectionItem"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>점검 항목</FormLabel>
+ <FormControl>
+ <Input {...field} disabled={pending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Input {...field} disabled={pending} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 활성 체크 */}
+ <FormField
+ control={form.control}
+ name="isActive"
+ render={({ field }) => (
+ <FormItem className="flex items-center gap-2">
+ <FormLabel className="m-0">활성</FormLabel>
+ <FormControl>
+ <input
+ type="checkbox"
+ checked={field.value}
+ onChange={(e) => field.onChange(e.target.checked)}
+ disabled={pending}
+ />
+ </FormControl>
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="pt-4">
+ <SheetClose asChild>
+ <Button variant="outline" disabled={pending}>
+ 취소
+ </Button>
+ </SheetClose>
+ <Button type="submit" disabled={pending}>
+ {pending ? "저장중..." : "저장"}
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ );
+}
diff --git a/lib/general-check-list/validation.ts b/lib/general-check-list/validation.ts
new file mode 100644
index 00000000..d1cd8ab3
--- /dev/null
+++ b/lib/general-check-list/validation.ts
@@ -0,0 +1,30 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { GeneralEvaluation } from "@/db/schema";
+
+
+export const getGenralEvaluationsSchema =createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<GeneralEvaluation>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+});
+
+export type GetGeneralEvaluationsSchema = Awaited<ReturnType<typeof getGenralEvaluationsSchema.parse>> \ No newline at end of file