diff options
Diffstat (limited to 'lib/pq/pq-criteria')
| -rw-r--r-- | lib/pq/pq-criteria/add-pq-dialog.tsx | 344 | ||||
| -rw-r--r-- | lib/pq/pq-criteria/delete-pqs-dialog.tsx | 149 | ||||
| -rw-r--r-- | lib/pq/pq-criteria/import-pq-button.tsx | 270 | ||||
| -rw-r--r-- | lib/pq/pq-criteria/import-pq-handler.tsx | 142 | ||||
| -rw-r--r-- | lib/pq/pq-criteria/pq-excel-template.tsx | 205 | ||||
| -rw-r--r-- | lib/pq/pq-criteria/pq-table-column.tsx | 232 | ||||
| -rw-r--r-- | lib/pq/pq-criteria/pq-table-toolbar-actions.tsx | 86 | ||||
| -rw-r--r-- | lib/pq/pq-criteria/pq-table.tsx | 127 | ||||
| -rw-r--r-- | lib/pq/pq-criteria/update-pq-sheet.tsx | 330 |
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 |
