summaryrefslogtreecommitdiff
path: root/lib/pq/pq-criteria
diff options
context:
space:
mode:
Diffstat (limited to 'lib/pq/pq-criteria')
-rw-r--r--lib/pq/pq-criteria/add-pq-dialog.tsx344
-rw-r--r--lib/pq/pq-criteria/delete-pqs-dialog.tsx149
-rw-r--r--lib/pq/pq-criteria/import-pq-button.tsx270
-rw-r--r--lib/pq/pq-criteria/import-pq-handler.tsx142
-rw-r--r--lib/pq/pq-criteria/pq-excel-template.tsx205
-rw-r--r--lib/pq/pq-criteria/pq-table-column.tsx232
-rw-r--r--lib/pq/pq-criteria/pq-table-toolbar-actions.tsx86
-rw-r--r--lib/pq/pq-criteria/pq-table.tsx127
-rw-r--r--lib/pq/pq-criteria/update-pq-sheet.tsx330
9 files changed, 1885 insertions, 0 deletions
diff --git a/lib/pq/pq-criteria/add-pq-dialog.tsx b/lib/pq/pq-criteria/add-pq-dialog.tsx
new file mode 100644
index 00000000..53fe28f1
--- /dev/null
+++ b/lib/pq/pq-criteria/add-pq-dialog.tsx
@@ -0,0 +1,344 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Plus } from "lucide-react"
+import { useRouter } from "next/navigation"
+
+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 { Textarea } from "@/components/ui/textarea"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+
+import { useToast } from "@/hooks/use-toast"
+import { createPqCriteria } from "../service"
+
+// PQ 생성을 위한 Zod 스키마 정의
+const createPqSchema = z.object({
+ code: z.string().min(1, "Code is required"),
+ checkPoint: z.string().min(1, "Check point is required"),
+ groupName: z.string().min(1, "Group is required"),
+ subGroupName: z.string().min(1, "Sub group is required"),
+ description: z.string().optional(),
+ remarks: z.string().optional(),
+ inputFormat: z.string().default("TEXT"),
+
+});
+
+type CreatePqFormType = z.infer<typeof createPqSchema>;
+
+// 그룹 이름 옵션
+export const groupOptions = [
+ "GENERAL",
+ "QMS",
+ "Warranty",
+ "HSE+",
+ "기타",
+];
+
+// 입력 형식 옵션
+const inputFormatOptions = [
+ { value: "TEXT", label: "텍스트" },
+ { value: "FILE", label: "파일" },
+ { value: "EMAIL", label: "이메일" },
+ { value: "PHONE", label: "전화번호" },
+ { value: "NUMBER", label: "숫자" },
+ { value: "TEXT_FILE", label: "텍스트 + 파일" },
+];
+
+interface AddPqDialogProps {
+ pqListId: number;
+}
+
+export function AddPqDialog({ pqListId }: AddPqDialogProps) {
+ const [open, setOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const router = useRouter()
+ const { toast } = useToast()
+
+ // react-hook-form 설정
+ const form = useForm<CreatePqFormType>({
+ resolver: zodResolver(createPqSchema),
+ defaultValues: {
+ code: "",
+ checkPoint: "",
+ groupName: groupOptions[0],
+ subGroupName: "",
+ description: "",
+ remarks: "",
+ inputFormat: "TEXT",
+
+ },
+ })
+ const formState = form.formState
+
+ async function onSubmit(data: CreatePqFormType) {
+ try {
+ setIsSubmitting(true)
+
+ // 서버 액션 호출
+ const result = await createPqCriteria(pqListId, data)
+
+ if (!result.success) {
+ toast({
+ title: "오류",
+ description: result.message || "PQ 항목 생성에 실패했습니다",
+ variant: "destructive",
+ })
+ return
+ }
+
+ // 성공 시 처리
+ toast({
+ title: "성공",
+ description: result.message || "PQ 항목이 성공적으로 생성되었습니다",
+ })
+
+ // 모달 닫고 폼 리셋
+ form.reset()
+ setOpen(false)
+
+ // 페이지 새로고침
+ router.refresh()
+
+ } catch (error) {
+ console.error('Error creating PQ criteria:', error)
+ toast({
+ title: "오류",
+ description: "예상치 못한 오류가 발생했습니다",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ <Plus className="size-4" />
+ Add PQ
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="sm:max-w-[600px] max-h-[80vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>PQ 항목 생성</DialogTitle>
+ <DialogDescription>
+ 새 PQ 항목을 추가합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 overflow-auto space-y-4">
+ <div className="space-y-4 px-1">
+ {/* Group Name 필드 */}
+ <FormField
+ control={form.control}
+ name="groupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대분류 <span className="text-destructive">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="그룹을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {groupOptions.map((group) => (
+ <SelectItem key={group} value={group}>
+ {group}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Sub Group Name 필드 */}
+ <FormField
+ control={form.control}
+ name="subGroupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>소분류 <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="서브 그룹명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormDescription>
+ 세부 분류를 위한 서브 그룹명을 입력하세요 (선택사항)
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* Code 필드 */}
+ <FormField
+ control={form.control}
+ name="code"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>일련번호 <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1-1, A.2.3"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ PQ 항목의 고유 코드를 입력하세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* Check Point 필드 */}
+ <FormField
+ control={form.control}
+ name="checkPoint"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>PQ 항목 <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="PQ 항목을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Input Format 필드 */}
+ <FormField
+ control={form.control}
+ name="inputFormat"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>협력업체 입력사항 <span className="text-destructive">*</span></FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입력 형식을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {inputFormatOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Description 필드 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설명</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 설명을 입력하세요"
+ className="min-h-[100px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remarks 필드 */}
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고 사항을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => {
+ form.reset();
+ setOpen(false);
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting || !formState.isValid}
+ >
+ {isSubmitting ? "생성 중..." : "생성"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-criteria/delete-pqs-dialog.tsx b/lib/pq/pq-criteria/delete-pqs-dialog.tsx
new file mode 100644
index 00000000..45fa065f
--- /dev/null
+++ b/lib/pq/pq-criteria/delete-pqs-dialog.tsx
@@ -0,0 +1,149 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Trash } from "lucide-react"
+import { toast } from "sonner"
+
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import { PqCriterias } from "@/db/schema/pq"
+import { deletePqCriterias } from "../service"
+
+
+interface DeleteTasksDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ pqs: Row<PqCriterias>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeletePqsDialog({
+ pqs,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteTasksDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+ const result = await deletePqCriterias(pqs.map((pq) => pq.id))
+
+ if (!result.success) {
+ toast.error(result.message || "PQ 항목 삭제에 실패했습니다")
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success(result.message || "PQ 항목이 삭제되었습니다")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({pqs.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle>
+ <DialogDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{pqs.length}개</span>의 PQ 항목이 영구적으로 삭제됩니다.
+ <br />
+ <span className="text-red-600 font-medium">삭제 시 하위 PQ항목들 전부 삭제됩니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">취소</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ 삭제
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({pqs.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle>
+ <DrawerDescription>
+ 이 작업은 되돌릴 수 없습니다. 선택한{" "}
+ <span className="font-medium">{pqs.length}개</span>의 PQ 항목이 영구적으로 삭제됩니다.
+ <br />
+ <span className="text-red-600 font-medium">삭제 시 하위 PQ항목들 전부 삭제됩니다.</span>
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">취소</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ 삭제
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/pq/pq-criteria/import-pq-button.tsx b/lib/pq/pq-criteria/import-pq-button.tsx
new file mode 100644
index 00000000..d338abd4
--- /dev/null
+++ b/lib/pq/pq-criteria/import-pq-button.tsx
@@ -0,0 +1,270 @@
+"use client"
+
+import * as React from "react"
+import { Upload } from "lucide-react"
+import { toast } from "sonner"
+import * as ExcelJS from 'exceljs'
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Progress } from "@/components/ui/progress"
+import { processFileImport } from "./import-pq-handler" // 별도 파일로 분리
+import { decryptWithServerAction } from "@/components/drm/drmUtils"
+
+interface ImportPqButtonProps {
+ projectId?: number | null
+ onSuccess?: () => void
+}
+
+export function ImportPqButton({ projectId, onSuccess }: ImportPqButtonProps) {
+ const [open, setOpen] = React.useState(false)
+ const [file, setFile] = React.useState<File | null>(null)
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [progress, setProgress] = React.useState(0)
+ const [error, setError] = React.useState<string | null>(null)
+ const fileInputRef = React.useRef<HTMLInputElement>(null)
+
+ // 파일 선택 처리
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
+ const selectedFile = e.target.files?.[0]
+ if (!selectedFile) return
+
+ if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) {
+ setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.")
+ return
+ }
+
+ setFile(selectedFile)
+ setError(null)
+ }
+
+ // 데이터 가져오기 처리
+ const handleImport = async () => {
+ if (!file) {
+ setError("가져올 파일을 선택해주세요.")
+ return
+ }
+
+ try {
+ setIsUploading(true)
+ setProgress(0)
+ setError(null)
+
+ // DRM 복호화 처리 - 서버 액션 직접 호출
+ let arrayBuffer: ArrayBuffer;
+ try {
+ setProgress(10);
+ toast.info("파일 복호화 중...");
+ arrayBuffer = await decryptWithServerAction(file);
+ setProgress(30);
+ } catch (decryptError) {
+ console.error("파일 복호화 실패, 원본 파일 사용:", decryptError);
+ toast.warning("파일 복호화에 실패하여 원본 파일을 사용합니다.");
+ // 복호화 실패 시 원본 파일 사용
+ arrayBuffer = await file.arrayBuffer();
+ }
+
+ // ExcelJS 워크북 로드
+ const workbook = new ExcelJS.Workbook();
+ await workbook.xlsx.load(arrayBuffer);
+
+ // 첫 번째 워크시트 가져오기
+ const worksheet = workbook.worksheets[0];
+ if (!worksheet) {
+ throw new Error("Excel 파일에 워크시트가 없습니다.");
+ }
+
+ // 헤더 행 번호 찾기 (보통 지침 행이 있으므로 헤더는 뒤에 위치)
+ let headerRowIndex = 1;
+ let headerRow: ExcelJS.Row | undefined;
+ let headerValues: (string | null)[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ const values = row.values as (string | null)[];
+ if (!headerRow && values.some(v => v === "Code" || v === "Check Point") && rowNumber > 1) {
+ headerRowIndex = rowNumber;
+ headerRow = row;
+ headerValues = [...values];
+ }
+ });
+
+ if (!headerRow) {
+ throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다.");
+ }
+
+ // 헤더를 기반으로 인덱스 매핑 생성
+ const headerMapping: Record<string, number> = {};
+ headerValues.forEach((value, index) => {
+ if (typeof value === 'string') {
+ headerMapping[value] = index;
+ }
+ });
+
+ // 필수 헤더 확인
+ const requiredHeaders = ["Code", "Check Point", "Group Name"];
+ const missingHeaders = requiredHeaders.filter(header => !(header in headerMapping));
+
+ if (missingHeaders.length > 0) {
+ throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`);
+ }
+
+ // 데이터 행 추출 (헤더 이후 행부터)
+ const dataRows: Record<string, any>[] = [];
+
+ worksheet.eachRow((row, rowNumber) => {
+ if (rowNumber > headerRowIndex) {
+ const rowData: Record<string, any> = {};
+ const values = row.values as (string | null | undefined)[];
+
+ Object.entries(headerMapping).forEach(([header, index]) => {
+ rowData[header] = values[index] || "";
+ });
+
+ // 빈 행이 아닌 경우만 추가
+ if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) {
+ dataRows.push(rowData);
+ }
+ }
+ });
+
+ if (dataRows.length === 0) {
+ throw new Error("Excel 파일에 가져올 데이터가 없습니다.");
+ }
+
+ // 진행 상황 업데이트를 위한 콜백
+ const updateProgress = (current: number, total: number) => {
+ const percentage = Math.round((current / total) * 100);
+ setProgress(percentage);
+ };
+
+ // 실제 데이터 처리는 별도 함수에서 수행
+ const result = await processFileImport(
+ dataRows,
+ projectId,
+ updateProgress
+ );
+
+ // 처리 완료
+ toast.success(`${result.successCount}개의 PQ 항목이 성공적으로 가져와졌습니다.`);
+
+ if (result.errorCount > 0) {
+ toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`);
+ }
+
+ // 상태 초기화 및 다이얼로그 닫기
+ setFile(null);
+ setOpen(false);
+
+ // 성공 콜백 호출
+ if (onSuccess) {
+ onSuccess();
+ }
+ } catch (error) {
+ console.error("Excel 파일 처리 중 오류 발생:", error);
+ setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다.");
+ } finally {
+ setIsUploading(false);
+ }
+ };
+
+ // 다이얼로그 열기/닫기 핸들러
+ const handleOpenChange = (newOpen: boolean) => {
+ if (!newOpen) {
+ // 닫을 때 상태 초기화
+ setFile(null)
+ setError(null)
+ setProgress(0)
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ""
+ }
+ }
+ setOpen(newOpen)
+ }
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => setOpen(true)}
+ disabled={isUploading}
+ >
+ <Upload className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Import</span>
+ </Button>
+
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>PQ 항목 가져오기</DialogTitle>
+ <DialogDescription>
+ {projectId
+ ? "프로젝트별 PQ 항목을 Excel 파일에서 가져옵니다."
+ : "일반 PQ 항목을 Excel 파일에서 가져옵니다."}
+ <br />
+ 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4 py-4">
+ <div className="flex items-center gap-4">
+ <input
+ type="file"
+ ref={fileInputRef}
+ className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium"
+ accept=".xlsx,.xls"
+ onChange={handleFileChange}
+ disabled={isUploading}
+ />
+ </div>
+
+ {file && (
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB)
+ </div>
+ )}
+
+ {isUploading && (
+ <div className="space-y-2">
+ <Progress value={progress} />
+ <p className="text-sm text-muted-foreground text-center">
+ {progress}% 완료
+ </p>
+ </div>
+ )}
+
+ {error && (
+ <div className="text-sm font-medium text-destructive">
+ {error}
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleImport}
+ disabled={!file || isUploading}
+ >
+ {isUploading ? "처리 중..." : "가져오기"}
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-criteria/import-pq-handler.tsx b/lib/pq/pq-criteria/import-pq-handler.tsx
new file mode 100644
index 00000000..3ee30c88
--- /dev/null
+++ b/lib/pq/pq-criteria/import-pq-handler.tsx
@@ -0,0 +1,142 @@
+"use client"
+
+import { z } from "zod"
+import { createPqCriteria } from "../service" // PQ 생성 서버 액션
+
+// PQ 데이터 검증을 위한 Zod 스키마
+const pqItemSchema = z.object({
+ code: z.string().min(1, "Code is required"),
+ checkPoint: z.string().min(1, "Check point is required"),
+ groupName: z.string().min(1, "Group is required"),
+ description: z.string().optional().nullable(),
+ remarks: z.string().optional().nullable(),
+ contractInfo: z.string().optional().nullable(),
+ additionalRequirement: z.string().optional().nullable(),
+});
+
+// 지원하는 그룹 이름 목록
+const validGroupNames = [
+ "GENERAL",
+ "Quality Management System",
+ "Workshop & Environment",
+ "Warranty",
+];
+
+type ImportPqItem = z.infer<typeof pqItemSchema>;
+
+interface ProcessResult {
+ successCount: number;
+ errorCount: number;
+ errors?: Array<{ row: number; message: string }>;
+}
+
+/**
+ * Excel 파일에서 가져온 PQ 데이터를 처리하는 함수
+ */
+export async function processFileImport(
+ jsonData: any[],
+ pqListId: number,
+ progressCallback?: (current: number, total: number) => void
+): Promise<ProcessResult> {
+ // 결과 카운터 초기화
+ let successCount = 0;
+ let errorCount = 0;
+ const errors: Array<{ row: number; message: string }> = [];
+
+ // 헤더 행과 지침 행 건너뛰기
+ const dataRows = jsonData.filter(row => {
+ // 행이 문자열로만 구성된 경우 지침 행으로 간주
+ if (Object.values(row).every(val => typeof val === 'string' && !val.includes(':'))) {
+ return false;
+ }
+ // 빈 행 건너뛰기
+ if (Object.values(row).every(val => !val)) {
+ return false;
+ }
+ return true;
+ });
+
+ // 데이터 행이 없으면 빈 결과 반환
+ if (dataRows.length === 0) {
+ return { successCount: 0, errorCount: 0 };
+ }
+
+ // 각 행에 대해 처리
+ for (let i = 0; i < dataRows.length; i++) {
+ const row = dataRows[i];
+ const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작
+
+ // 진행 상황 콜백 호출
+ if (progressCallback) {
+ progressCallback(i + 1, dataRows.length);
+ }
+
+ try {
+ // 데이터 정제
+ const cleanedRow: ImportPqItem = {
+ code: row.Code?.toString().trim() ?? "",
+ checkPoint: row["Check Point"]?.toString().trim() ?? "",
+ groupName: row["Group Name"]?.toString().trim() ?? "",
+ description: row.Description?.toString() ?? "",
+ remarks: row.Remarks?.toString() ?? "",
+ contractInfo: row["Contract Info"]?.toString() ?? "",
+ additionalRequirement: row["Additional Requirements"]?.toString() ?? "",
+ };
+
+ // 데이터 유효성 검사
+ const validationResult = pqItemSchema.safeParse(cleanedRow);
+
+ if (!validationResult.success) {
+ const errorMessage = validationResult.error.errors.map(
+ err => `${err.path.join('.')}: ${err.message}`
+ ).join(', ');
+
+ errors.push({ row: rowIndex, message: errorMessage });
+ errorCount++;
+ continue;
+ }
+
+ // 그룹 이름 유효성 검사
+ if (!validGroupNames.includes(cleanedRow.groupName)) {
+ errors.push({
+ row: rowIndex,
+ message: `Invalid group name: ${cleanedRow.groupName}. Must be one of: ${validGroupNames.join(', ')}`
+ });
+ errorCount++;
+ continue;
+ }
+
+ // PQ 생성 서버 액션 호출
+ const createResult = await createPqCriteria(pqListId, cleanedRow);
+
+ if (createResult.success) {
+ successCount++;
+ } else {
+ errors.push({
+ row: rowIndex,
+ message: createResult.message || "Unknown error"
+ });
+ errorCount++;
+ }
+ } catch (error) {
+ console.error(`Row ${rowIndex} processing error:`, error);
+ errors.push({
+ row: rowIndex,
+ message: error instanceof Error ? error.message : "Unknown error"
+ });
+ errorCount++;
+ }
+
+ // 비동기 작업 쓰로틀링
+ if (i % 5 === 0) {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ }
+
+ // 처리 결과 반환
+ return {
+ successCount,
+ errorCount,
+ errors: errors.length > 0 ? errors : undefined
+ };
+} \ No newline at end of file
diff --git a/lib/pq/pq-criteria/pq-excel-template.tsx b/lib/pq/pq-criteria/pq-excel-template.tsx
new file mode 100644
index 00000000..aa8c1b3a
--- /dev/null
+++ b/lib/pq/pq-criteria/pq-excel-template.tsx
@@ -0,0 +1,205 @@
+"use client"
+
+import * as ExcelJS from 'exceljs';
+import { saveAs } from 'file-saver';
+import { toast } from 'sonner';
+
+/**
+ * PQ 기준 Excel 템플릿을 다운로드하는 함수 (exceljs 사용)
+ * @param isProjectSpecific 프로젝트별 PQ 템플릿 여부
+ */
+export async function exportPqTemplate(isProjectSpecific: boolean = false) {
+ try {
+ // 워크북 생성
+ const workbook = new ExcelJS.Workbook();
+
+ // 워크시트 생성
+ const sheetName = isProjectSpecific ? "Project PQ Template" : "General PQ Template";
+ const worksheet = workbook.addWorksheet(sheetName);
+
+ // 그룹 옵션 정의 - 드롭다운 목록에 사용
+ const groupOptions = [
+ "GENERAL",
+ "Quality Management System",
+ "Workshop & Environment",
+ "Warranty",
+ ];
+
+ // 일반 PQ 필드 (기본 필드)
+ const basicFields = [
+ { header: "Code", key: "code", width: 90 },
+ { header: "Check Point", key: "checkPoint", width: 180 },
+ { header: "Group Name", key: "groupName", width: 150 },
+ { header: "Description", key: "description", width: 240 },
+ { header: "Remarks", key: "remarks", width: 180 },
+ ];
+
+ // 프로젝트별 PQ 추가 필드
+ const projectFields = isProjectSpecific
+ ? [
+ { header: "Contract Info", key: "contractInfo", width: 180 },
+ { header: "Additional Requirements", key: "additionalRequirement", width: 240 },
+ ]
+ : [];
+
+ // 모든 필드 합치기
+ const fields = [...basicFields, ...projectFields];
+
+ // 지침 행 추가
+ const instructionTitle = worksheet.addRow(["Instructions:"]);
+ instructionTitle.font = { bold: true, size: 12 };
+ worksheet.mergeCells(1, 1, 1, fields.length);
+
+ const instructions = [
+ "1. 'Code' 필드는 고유해야 합니다 (예: 1-1, A.2.3).",
+ "2. 'Check Point'는 필수 항목입니다.",
+ "3. 'Group Name'은 드롭다운 목록에서 선택하세요: GENERAL, Quality Management System, Workshop & Environment, Warranty",
+ "4. 여러 줄 텍스트는 \\n으로 줄바꿈을 표시합니다.",
+ "5. 아래 회색 배경의 예시 행은 참고용입니다. 실제 데이터 입력 전에 이 행을 수정하거나 삭제해야 합니다.",
+ ];
+
+ // 프로젝트별 PQ일 경우 추가 지침
+ if (isProjectSpecific) {
+ instructions.push(
+ "6. 'Contract Info'와 'Additional Requirements'는 프로젝트별 세부 정보를 위한 필드입니다."
+ );
+ }
+
+ // 지침 행 추가
+ instructions.forEach((instruction, idx) => {
+ const row = worksheet.addRow([instruction]);
+ worksheet.mergeCells(idx + 2, 1, idx + 2, fields.length);
+ row.font = { color: { argb: '00808080' } };
+ });
+
+ // 빈 행 추가
+ worksheet.addRow([]);
+
+ // 헤더 행 추가
+ const headerRow = worksheet.addRow(fields.map(field => field.header));
+ headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } };
+ headerRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FF4472C4' }
+ };
+ headerRow.alignment = { vertical: 'middle', horizontal: 'center' };
+
+ // 예시 행 표시를 위한 첫 번째 열 값 수정
+ const exampleData: Record<string, string> = {
+ code: "[예시 - 수정/삭제 필요] 1-1",
+ checkPoint: "Selling / 1 year Property",
+ groupName: "GENERAL",
+ description: "Address :\nTel. / Fax :\ne-mail :",
+ remarks: "Optional remarks",
+ };
+
+ // 프로젝트별 PQ인 경우 예시 데이터에 추가 필드 추가
+ if (isProjectSpecific) {
+ exampleData.contractInfo = "Contract details for this project";
+ exampleData.additionalRequirement = "Additional technical requirements";
+ }
+
+ const exampleRow = worksheet.addRow(fields.map(field => exampleData[field.key] || ""));
+ exampleRow.font = { italic: true };
+ exampleRow.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFEDEDED' }
+ };
+ // 예시 행 첫 번째 셀에 코멘트 추가
+ const codeCell = worksheet.getCell(exampleRow.number, 1);
+ codeCell.note = '이 예시 행은 참고용입니다. 실제 데이터 입력 전에 수정하거나 삭제하세요.';
+
+ // Group Name 열 인덱스 찾기 (0-based)
+ const groupNameIndex = fields.findIndex(field => field.key === "groupName");
+
+ // 열 너비 설정
+ fields.forEach((field, index) => {
+ const column = worksheet.getColumn(index + 1);
+ column.width = field.width / 6.5; // ExcelJS에서는 픽셀과 다른 단위 사용
+ });
+
+ // 각 셀에 테두리 추가
+ const headerRowNum = instructions.length + 3;
+ const exampleRowNum = headerRowNum + 1;
+
+ for (let i = 1; i <= fields.length; i++) {
+ // 헤더 행에 테두리 추가
+ worksheet.getCell(headerRowNum, i).border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+
+ // 예시 행에 테두리 추가
+ worksheet.getCell(exampleRowNum, i).border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+ }
+
+ // 사용자 입력용 빈 행 추가 (10개)
+ for (let rowIdx = 0; rowIdx < 10; rowIdx++) {
+ // 빈 행 추가
+ const emptyRow = worksheet.addRow(Array(fields.length).fill(''));
+ const currentRowNum = exampleRowNum + 1 + rowIdx;
+
+ // 각 셀에 테두리 추가
+ for (let colIdx = 1; colIdx <= fields.length; colIdx++) {
+ const cell = worksheet.getCell(currentRowNum, colIdx);
+ cell.border = {
+ top: { style: 'thin' },
+ left: { style: 'thin' },
+ bottom: { style: 'thin' },
+ right: { style: 'thin' }
+ };
+
+ // Group Name 열에 데이터 유효성 검사 (드롭다운) 추가
+ if (colIdx === groupNameIndex + 1) {
+ cell.dataValidation = {
+ type: 'list',
+ allowBlank: true,
+ formulae: [`"${groupOptions.join(',')}"`],
+ showErrorMessage: true,
+ errorStyle: 'error',
+ error: '유효하지 않은 그룹입니다',
+ errorTitle: '입력 오류',
+ prompt: '목록에서 선택하세요',
+ promptTitle: '그룹 선택'
+ };
+ }
+ }
+ }
+
+ // 예시 행이 있는 열에도 Group Name 드롭다운 적용
+ const exampleGroupCell = worksheet.getCell(exampleRowNum, groupNameIndex + 1);
+ exampleGroupCell.dataValidation = {
+ type: 'list',
+ allowBlank: true,
+ formulae: [`"${groupOptions.join(',')}"`],
+ showErrorMessage: true,
+ errorStyle: 'error',
+ error: '유효하지 않은 그룹입니다',
+ errorTitle: '입력 오류',
+ prompt: '목록에서 선택하세요',
+ promptTitle: '그룹 선택'
+ };
+
+ // 워크북을 Excel 파일로 변환
+ const buffer = await workbook.xlsx.writeBuffer();
+
+ // 파일명 설정 및 저장
+ const fileName = isProjectSpecific ? "project-pq-template.xlsx" : "general-pq-template.xlsx";
+ const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
+ saveAs(blob, fileName);
+
+ toast.success(`${isProjectSpecific ? '프로젝트별' : '일반'} PQ 템플릿이 다운로드되었습니다.`);
+ } catch (error) {
+ console.error("템플릿 다운로드 중 오류 발생:", error);
+ toast.error("템플릿 다운로드 중 오류가 발생했습니다.");
+ }
+} \ No newline at end of file
diff --git a/lib/pq/pq-criteria/pq-table-column.tsx b/lib/pq/pq-criteria/pq-table-column.tsx
new file mode 100644
index 00000000..924d80c4
--- /dev/null
+++ b/lib/pq/pq-criteria/pq-table-column.tsx
@@ -0,0 +1,232 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { DataTableRowAction } from "@/types/table"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuShortcut,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+import { Button } from "@/components/ui/button"
+import { Ellipsis } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { PqCriterias } from "@/db/schema/pq"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PqCriterias> | null>>
+}
+
+export function getColumns({
+ setRowAction,
+}: GetColumnsProps): ColumnDef<PqCriterias>[] {
+ return [
+ {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ size:40,
+ enableSorting: false,
+ enableHiding: false,
+ },
+
+ {
+ accessorKey: "groupName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="대분류" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("groupName")}</div>,
+ meta: {
+ excelHeader: "Group Name"
+ },
+ enableResizing: true,
+ minSize: 60,
+ size: 100,
+ },
+ {
+ accessorKey: "subGroupName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="소분류" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("subGroupName") || "-"}</div>,
+ meta: {
+ excelHeader: "Sub Group Name"
+ },
+ enableResizing: true,
+ minSize: 60,
+ size: 100,
+ },
+ {
+ accessorKey: "code",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="일련번호" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("code")}</div>,
+ meta: {
+ excelHeader: "Code"
+ },
+ enableResizing: true,
+ minSize: 50,
+ size: 100,
+ },
+ {
+ accessorKey: "checkPoint",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 항목" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("checkPoint")}</div>,
+ meta: {
+ excelHeader: "Check Point"
+ },
+ enableResizing: true,
+ minSize: 180,
+ size: 180,
+ },
+
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="설명" />
+ ),
+ cell: ({ row }) => {
+ const text = row.getValue("description") as string
+ return (
+ <div style={{ whiteSpace: "pre-wrap" }}>
+ {text}
+ </div>
+ )
+ },
+ meta: {
+ excelHeader: "Description"
+ },
+ enableResizing: true,
+ minSize: 180,
+ size: 180,
+ },
+ // {
+ // accessorKey: "remarks",
+ // header: ({ column }) => (
+ // <DataTableColumnHeaderSimple column={column} title="SHI Comment" />
+ // ),
+ // cell: ({ row }) => {
+ // const text = row.getValue("remarks") as string
+ // return (
+ // <div style={{ whiteSpace: "pre-wrap" }}>
+ // {text || "-"}
+ // </div>
+ // )
+ // },
+ // meta: {
+ // excelHeader: "Remarks"
+ // },
+ // enableResizing: true,
+ // minSize: 180,
+ // size: 180,
+ // },
+ {
+ accessorKey: "inputFormat",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="협력업체 입력사항" />
+ ),
+ cell: ({ row }) => {
+ const format = row.getValue("inputFormat") as string
+ const formatLabels = {
+ TEXT: "텍스트",
+ FILE: "파일",
+ EMAIL: "이메일",
+ PHONE: "전화번호",
+ NUMBER: "숫자",
+ "TEXT_FILE": "텍스트 + 파일"
+ }
+ return (
+ <Badge variant="outline">
+ {formatLabels[format as keyof typeof formatLabels] || format}
+ </Badge>
+ )
+ },
+ meta: {
+ excelHeader: "Input Format"
+ },
+ enableResizing: true,
+ minSize: 100,
+ size: 120,
+ },
+
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date, "ko-KR"),
+ enableResizing: true,
+ minSize: 180,
+ size: 180,
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="수정일" />
+ ),
+ cell: ({ cell }) => formatDate(cell.getValue() as Date, "ko-KR"),
+ enableResizing: true,
+ minSize: 180,
+ size: 180,
+ },
+ {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-7 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+ }
+ ]
+} \ No newline at end of file
diff --git a/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx b/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx
new file mode 100644
index 00000000..f168b83d
--- /dev/null
+++ b/lib/pq/pq-criteria/pq-table-toolbar-actions.tsx
@@ -0,0 +1,86 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+// import { Download, FileDown } from "lucide-react"
+// import { toast } from "sonner"
+
+// import { exportTableToExcel } from "@/lib/export"
+// import { Button } from "@/components/ui/button"
+// import {
+// DropdownMenu,
+// DropdownMenuContent,
+// DropdownMenuItem,
+// DropdownMenuTrigger,
+// } from "@/components/ui/dropdown-menu"
+
+import { DeletePqsDialog } from "./delete-pqs-dialog"
+import { AddPqDialog } from "./add-pq-dialog"
+import { PqCriterias } from "@/db/schema/pq"
+// import { ImportPqButton } from "./import-pq-button"
+// import { exportPqTemplate } from "./pq-excel-template"
+
+interface PqTableToolbarActionsProps {
+ table: Table<PqCriterias>
+ pqListId: number
+}
+
+export function PqTableToolbarActions({
+ table,
+ pqListId
+}: PqTableToolbarActionsProps) {
+ // const [refreshKey, setRefreshKey] = React.useState(0)
+
+ // // Import 성공 후 테이블 갱신
+ // const handleImportSuccess = () => {
+ // setRefreshKey(prev => prev + 1)
+ // }
+
+ return (
+ <div className="flex items-center gap-2">
+ {table.getFilteredSelectedRowModel().rows.length > 0 ? (
+ <DeletePqsDialog
+ pqs={table
+ .getFilteredSelectedRowModel()
+ .rows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ ) : null}
+
+ <AddPqDialog pqListId={pqListId} />
+
+ {/* Import 버튼 */}
+ {/* <ImportPqButton
+ pqListId={pqListId}
+ onSuccess={handleImportSuccess}
+ /> */}
+
+ {/* Export 드롭다운 메뉴 */}
+ {/* <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: `pq-list-${pqListId}-criteria`,
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ >
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>현재 데이터 내보내기</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={() => exportPqTemplate()}>
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>템플릿 다운로드</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu> */}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-criteria/pq-table.tsx b/lib/pq/pq-criteria/pq-table.tsx
new file mode 100644
index 00000000..e0e3dee5
--- /dev/null
+++ b/lib/pq/pq-criteria/pq-table.tsx
@@ -0,0 +1,127 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { getPQsByListId } from "../service"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { PqCriterias } from "@/db/schema/pq"
+import { DeletePqsDialog } from "./delete-pqs-dialog"
+import { PqTableToolbarActions } from "./pq-table-toolbar-actions"
+import { getColumns } from "./pq-table-column"
+import { UpdatePqSheet } from "./update-pq-sheet"
+
+interface DocumentListTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getPQsByListId>>]>
+ pqListId: number
+}
+
+export function PqsTable({
+ promises,
+ pqListId
+}: DocumentListTableProps) {
+ // 1) 데이터를 가져옴 (server component -> use(...) pattern)
+ const [{ data, pageCount }] = React.use(promises)
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PqCriterias> | null>(null)
+
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ // Filter fields
+ const filterFields: DataTableFilterField<PqCriterias>[] = []
+
+ const advancedFilterFields: DataTableAdvancedFilterField<PqCriterias>[] = [
+ {
+ id: "code",
+ label: "Code",
+ type: "text",
+ },
+ {
+ id: "checkPoint",
+ label: "Check Point",
+ type: "text",
+ },
+ {
+ id: "description",
+ label: "Description",
+ type: "text",
+ },
+ {
+ id: "remarks",
+ label: "Remarks",
+ type: "text",
+ },
+ {
+ id: "groupName",
+ label: "Group Name",
+ type: "text",
+ },
+ {
+ id: "createdAt",
+ label: "Created at",
+ type: "date",
+ },
+ {
+ id: "updatedAt",
+ label: "Updated at",
+ type: "date",
+ },
+ ]
+
+ // useDataTable 훅으로 react-table 구성
+ const { table } = useDataTable({
+ data: data, // <-- 여기서 tableData 사용
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ // grouping:['groupName']
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ columnResizeMode: "onEnd",
+
+ })
+ return (
+ <>
+ <DataTable table={table} >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <PqTableToolbarActions table={table} pqListId={pqListId}/>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <UpdatePqSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ pq={rowAction?.row.original ?? null}
+ />
+
+ <DeletePqsDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ pqs={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/pq/pq-criteria/update-pq-sheet.tsx b/lib/pq/pq-criteria/update-pq-sheet.tsx
new file mode 100644
index 00000000..245627e6
--- /dev/null
+++ b/lib/pq/pq-criteria/update-pq-sheet.tsx
@@ -0,0 +1,330 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Loader, Save } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { toast } from "sonner"
+import { z } from "zod"
+import { useRouter } from "next/navigation"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ // SelectGroup,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+import { updatePqCriteria } from "../service"
+import { groupOptions } from "./add-pq-dialog"
+import { Checkbox } from "@/components/ui/checkbox"
+
+// PQ 수정을 위한 Zod 스키마 정의
+const updatePqSchema = z.object({
+ code: z.string().min(1, "Code is required"),
+ checkPoint: z.string().min(1, "Check point is required"),
+ groupName: z.string().min(1, "Group is required"),
+ description: z.string().optional(),
+ remarks: z.string().optional(),
+ inputFormat: z.string().default("TEXT"),
+
+ subGroupName: z.string().optional(),
+});
+
+type UpdatePqSchema = z.infer<typeof updatePqSchema>;
+
+// 입력 형식 옵션
+const inputFormatOptions = [
+ { value: "TEXT", label: "텍스트" },
+ { value: "FILE", label: "파일" },
+ { value: "EMAIL", label: "이메일" },
+ { value: "PHONE", label: "전화번호" },
+ { value: "NUMBER", label: "숫자" },
+ { value: "TEXT_FILE", label: "텍스트 + 파일" }
+];
+
+interface UpdatePqSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ pq: {
+ id: number;
+ code: string;
+ checkPoint: string;
+ description: string | null;
+ remarks: string | null;
+ groupName: string | null;
+ inputFormat: string;
+
+ subGroupName: string | null;
+ } | null
+}
+
+export function UpdatePqSheet({ pq, ...props }: UpdatePqSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const router = useRouter()
+
+ const form = useForm<UpdatePqSchema>({
+ resolver: zodResolver(updatePqSchema),
+ defaultValues: {
+ code: pq?.code ?? "",
+ checkPoint: pq?.checkPoint ?? "",
+ groupName: pq?.groupName ?? groupOptions[0],
+ description: pq?.description ?? "",
+ remarks: pq?.remarks ?? "",
+ inputFormat: pq?.inputFormat ?? "TEXT",
+
+ subGroupName: pq?.subGroupName ?? "",
+ },
+ })
+
+ // 폼 초기화 (pq가 변경될 때)
+ React.useEffect(() => {
+ if (pq) {
+ form.reset({
+ code: pq.code,
+ checkPoint: pq.checkPoint,
+ groupName: pq.groupName ?? groupOptions[0],
+ description: pq.description ?? "",
+ remarks: pq.remarks ?? "",
+ inputFormat: pq.inputFormat ?? "TEXT",
+
+ subGroupName: pq.subGroupName ?? "",
+ });
+ }
+ }, [pq, form]);
+
+ function onSubmit(input: UpdatePqSchema) {
+ startUpdateTransition(async () => {
+ if (!pq) return
+
+ const result = await updatePqCriteria(pq.id, input)
+
+ if (!result.success) {
+ toast.error(result.message || "PQ 항목 수정에 실패했습니다")
+ return
+ }
+
+ toast.success(result.message || "PQ 항목이 성공적으로 수정되었습니다")
+ form.reset()
+ props.onOpenChange?.(false)
+ router.refresh()
+ })
+ }
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update PQ Criteria</SheetTitle>
+ <SheetDescription>
+ Update the PQ criteria details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* Code 필드 */}
+ <FormField
+ control={form.control}
+ name="code"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Code <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 1-1, A.2.3"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Check Point 필드 */}
+ <FormField
+ control={form.control}
+ name="checkPoint"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Check Point <span className="text-destructive">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="검증 항목을 입력하세요"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Group Name 필드 (Select) */}
+ <FormField
+ control={form.control}
+ name="groupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Group <span className="text-destructive">*</span></FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ value={field.value}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="그룹을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {groupOptions.map((group) => (
+ <SelectItem key={group} value={group}>
+ {group}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* Sub Group Name 필드 */}
+ <FormField
+ control={form.control}
+ name="subGroupName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Sub Group Name</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="서브 그룹명을 입력하세요"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Input Format 필드 */}
+ <FormField
+ control={form.control}
+ name="inputFormat"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>입력 형식</FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="입력 형식을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {inputFormatOptions.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Required 체크박스 */}
+
+
+ {/* Description 필드 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Description</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="상세 설명을 입력하세요"
+ className="min-h-[120px] whitespace-pre-wrap"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormDescription>
+ 줄바꿈이 필요한 경우 Enter 키를 누르세요. 입력한 대로 저장됩니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remarks 필드 */}
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Remarks</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고 사항을 입력하세요"
+ className="min-h-[80px]"
+ {...field}
+ value={field.value || ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => form.reset()}
+ >
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ <Save className="mr-2 size-4" /> Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file