diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-02 09:54:08 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-02 09:54:08 +0000 |
| commit | dfdfae3018f8499240f48d28ce634f4a5c56e006 (patch) | |
| tree | 4493b172c061fa5bf4e94c083788110eb1507f6d /lib/pq/table | |
| parent | 21a72eeddc74cf775e2a76e2c569de970bd62a7f (diff) | |
벤더 코멘트 처리
Diffstat (limited to 'lib/pq/table')
| -rw-r--r-- | lib/pq/table/add-pq-dialog.tsx | 431 | ||||
| -rw-r--r-- | lib/pq/table/import-pq-button.tsx | 258 | ||||
| -rw-r--r-- | lib/pq/table/import-pq-handler.tsx | 146 | ||||
| -rw-r--r-- | lib/pq/table/pq-excel-template.tsx | 205 | ||||
| -rw-r--r-- | lib/pq/table/pq-table-toolbar-actions.tsx | 86 | ||||
| -rw-r--r-- | lib/pq/table/pq-table.tsx | 4 |
6 files changed, 964 insertions, 166 deletions
diff --git a/lib/pq/table/add-pq-dialog.tsx b/lib/pq/table/add-pq-dialog.tsx index 8164dbaf..1f374cd0 100644 --- a/lib/pq/table/add-pq-dialog.tsx +++ b/lib/pq/table/add-pq-dialog.tsx @@ -27,8 +27,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Checkbox } from "@/components/ui/checkbox" import { useToast } from "@/hooks/use-toast" import { createPq, invalidatePqCache } from "../service" +import { ProjectSelector } from "@/components/ProjectSelector" +import { type Project } from "@/lib/rfqs/service" +import { ScrollArea } from "@/components/ui/scroll-area" // PQ 생성을 위한 Zod 스키마 정의 const createPqSchema = z.object({ @@ -36,10 +40,15 @@ const createPqSchema = z.object({ 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() + remarks: z.string().optional(), + // 프로젝트별 PQ 여부 체크박스 + isProjectSpecific: z.boolean().default(false), + // 프로젝트 관련 추가 필드는 isProjectSpecific가 true일 때만 필수 + contractInfo: z.string().optional(), + additionalRequirement: z.string().optional(), }); -type CreatePqInputType = z.infer<typeof createPqSchema>; +type CreatePqFormType = z.infer<typeof createPqSchema>; // 그룹 이름 옵션 const groupOptions = [ @@ -54,36 +63,71 @@ const descriptionExample = `Address : Tel. / Fax : e-mail :`; -export function AddPqDialog() { +interface AddPqDialogProps { + currentProjectId?: number | null; // 현재 선택된 프로젝트 ID (옵션) +} + +export function AddPqDialog({ currentProjectId }: AddPqDialogProps) { const [open, setOpen] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) + const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) const router = useRouter() const { toast } = useToast() // react-hook-form 설정 - const form = useForm<CreatePqInputType>({ + const form = useForm<CreatePqFormType>({ resolver: zodResolver(createPqSchema), defaultValues: { code: "", checkPoint: "", groupName: groupOptions[0], description: "", - remarks: "" + remarks: "", + isProjectSpecific: !!currentProjectId, // 현재 프로젝트 ID가 있으면 기본값 true + contractInfo: "", + additionalRequirement: "", }, }) + // 프로젝트별 PQ 여부 상태 감시 + const isProjectSpecific = form.watch("isProjectSpecific") + + // 현재 프로젝트 ID가 있으면 선택된 프로젝트 설정 + React.useEffect(() => { + if (currentProjectId) { + form.setValue("isProjectSpecific", true) + } + }, [currentProjectId, form]) + // 예시 텍스트를 description 필드에 채우는 함수 const fillExampleText = () => { form.setValue("description", descriptionExample); }; - async function onSubmit(data: CreatePqInputType) { + async function onSubmit(data: CreatePqFormType) { try { setIsSubmitting(true) - + + // 서버 액션 호출용 데이터 준비 + const submitData = { + ...data, + projectId: data.isProjectSpecific ? selectedProject?.id || currentProjectId : null, + } + + // 프로젝트별 PQ인데 프로젝트가 선택되지 않은 경우 검증 + if (data.isProjectSpecific && !submitData.projectId) { + toast({ + title: "Error", + description: "Please select a project", + variant: "destructive", + }) + setIsSubmitting(false) + return + } + // 서버 액션 호출 - const result = await createPq(data) - + const result = await createPq(submitData) + if (!result.success) { toast({ title: "Error", @@ -94,20 +138,21 @@ export function AddPqDialog() { } await invalidatePqCache(); - + // 성공 시 처리 toast({ title: "Success", - description: "PQ criteria created successfully", + description: result.message || "PQ criteria created successfully", }) - + // 모달 닫고 폼 리셋 form.reset() + setSelectedProject(null) setOpen(false) - + // 페이지 새로고침 router.refresh() - + } catch (error) { console.error('Error creating PQ criteria:', error) toast({ @@ -123,10 +168,24 @@ export function AddPqDialog() { function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { form.reset() + setSelectedProject(null) } setOpen(nextOpen) } + // 프로젝트 선택 핸들러 + const handleProjectSelect = (project: Project | null) => { + // project가 null인 경우 선택 해제를 의미 + if (project === null) { + setSelectedProject(null); + // 필요한 경우 추가 처리 + return; + } + + // 기존 처리 - 프로젝트가 선택된 경우 + setSelectedProject(project); + } + return ( <Dialog open={open} onOpenChange={handleDialogOpenChange}> {/* 모달을 열기 위한 버튼 */} @@ -137,7 +196,7 @@ export function AddPqDialog() { </Button> </DialogTrigger> - <DialogContent className="sm:max-w-[550px]"> + <DialogContent className="sm:max-w-[600px]"> <DialogHeader> <DialogTitle>Create New PQ Criteria</DialogTitle> <DialogDescription> @@ -147,145 +206,241 @@ export function AddPqDialog() { {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2"> - {/* 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> - <FormDescription> - PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3") - </FormDescription> - <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} - > + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2 flex flex-col"> + {/* 프로젝트별 PQ 여부 체크박스 */} + + <div className="flex-1 overflow-auto px-4 space-y-4"> + <FormField + control={form.control} + name="isProjectSpecific" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> <FormControl> - <SelectTrigger> - <SelectValue placeholder="그룹을 선택하세요" /> - </SelectTrigger> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> </FormControl> - <SelectContent> - {groupOptions.map((group) => ( - <SelectItem key={group} value={group}> - {group} - </SelectItem> - ))} - </SelectContent> - </Select> + <div className="space-y-1 leading-none"> + <FormLabel>프로젝트별 PQ 생성</FormLabel> + <FormDescription> + 특정 프로젝트에만 적용되는 PQ 항목을 생성합니다 + </FormDescription> + </div> + </FormItem> + )} + /> + + {/* 프로젝트 선택 필드 (프로젝트별 PQ 선택 시에만 표시) */} + {isProjectSpecific && ( + <div className="space-y-2"> + <FormLabel>Project <span className="text-destructive">*</span></FormLabel> + <ProjectSelector + selectedProjectId={currentProjectId || selectedProject?.id} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 선택하세요" + /> <FormDescription> - PQ 항목의 분류 그룹을 선택하세요 + PQ 항목을 적용할 프로젝트를 선택하세요 </FormDescription> - <FormMessage /> - </FormItem> + </div> )} - /> - - {/* Description 필드 - 예시 템플릿 버튼 추가 */} - <FormField - control={form.control} - name="description" - render={({ field }) => ( - <FormItem> - <div className="flex items-center justify-between"> - <FormLabel>Description</FormLabel> - <Button - type="button" - variant="outline" - size="sm" - onClick={fillExampleText} - > - 예시 채우기 - </Button> - </div> - <FormControl> - <Textarea - placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`} - className="min-h-[120px] font-mono" - {...field} - value={field.value || ""} + + <div className="flex-1 overflow-auto px-2 py-2 space-y-4" style={{maxHeight:420}}> + + + {/* 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> + <FormDescription> + PQ 항목의 고유 코드를 입력하세요 (예: "1-1", "A.2.3") + </FormDescription> + <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> + <FormDescription> + PQ 항목의 분류 그룹을 선택하세요 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* Description 필드 - 예시 템플릿 버튼 추가 */} + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <div className="flex items-center justify-between"> + <FormLabel>Description</FormLabel> + <Button + type="button" + variant="outline" + size="sm" + onClick={fillExampleText} + > + 예시 채우기 + </Button> + </div> + <FormControl> + <Textarea + placeholder={`줄바꿈을 포함한 상세 설명을 입력하세요\n예:\n${descriptionExample}`} + className="min-h-[120px] font-mono" + {...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> + )} + /> + + {/* 프로젝트별 PQ일 경우 추가 필드 */} + {isProjectSpecific && ( + <> + {/* 계약 정보 필드 */} + <FormField + control={form.control} + name="contractInfo" + render={({ field }) => ( + <FormItem> + <FormLabel>Contract Info</FormLabel> + <FormControl> + <Textarea + placeholder="계약 관련 정보를 입력하세요" + className="min-h-[80px]" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormDescription> + 해당 프로젝트의 계약 관련 특이사항 + </FormDescription> + <FormMessage /> + </FormItem> + )} /> - </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 || ""} + + {/* 추가 요구사항 필드 */} + <FormField + control={form.control} + name="additionalRequirement" + render={({ field }) => ( + <FormItem> + <FormLabel>Additional Requirements</FormLabel> + <FormControl> + <Textarea + placeholder="추가 요구사항을 입력하세요" + className="min-h-[80px]" + {...field} + value={field.value || ""} + /> + </FormControl> + <FormDescription> + 프로젝트별 추가 요구사항 + </FormDescription> + <FormMessage /> + </FormItem> + )} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + </> + )} + </div> + </div> <DialogFooter> <Button type="button" variant="outline" onClick={() => { - form.reset(); - setOpen(false); - }} + form.reset(); + setSelectedProject(null); + setOpen(false); + }} > Cancel </Button> - <Button - type="submit" + <Button + type="submit" disabled={isSubmitting} > {isSubmitting ? "Creating..." : "Create"} diff --git a/lib/pq/table/import-pq-button.tsx b/lib/pq/table/import-pq-button.tsx new file mode 100644 index 00000000..e4e0147f --- /dev/null +++ b/lib/pq/table/import-pq-button.tsx @@ -0,0 +1,258 @@ +"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" // 별도 파일로 분리 + +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) + + // 파일을 ArrayBuffer로 읽기 + const 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/table/import-pq-handler.tsx b/lib/pq/table/import-pq-handler.tsx new file mode 100644 index 00000000..aa5e6c47 --- /dev/null +++ b/lib/pq/table/import-pq-handler.tsx @@ -0,0 +1,146 @@ +"use client" + +import { z } from "zod" +import { createPq } 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[], + projectId: number | null | undefined, + 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() ?? null, + remarks: row.Remarks?.toString() ?? null, + contractInfo: row["Contract Info"]?.toString() ?? null, + additionalRequirement: row["Additional Requirements"]?.toString() ?? null, + }; + + // 데이터 유효성 검사 + 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 createPq({ + ...cleanedRow, + projectId: projectId, + isProjectSpecific: !!projectId, + }); + + 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/table/pq-excel-template.tsx b/lib/pq/table/pq-excel-template.tsx new file mode 100644 index 00000000..aa8c1b3a --- /dev/null +++ b/lib/pq/table/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/table/pq-table-toolbar-actions.tsx b/lib/pq/table/pq-table-toolbar-actions.tsx index 1d151520..1790caf8 100644 --- a/lib/pq/table/pq-table-toolbar-actions.tsx +++ b/lib/pq/table/pq-table-toolbar-actions.tsx @@ -2,23 +2,41 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Send, Upload } from "lucide-react" +import { Download, FileDown, Upload } 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 DocTableToolbarActionsProps { +interface PqTableToolbarActionsProps { table: Table<PqCriterias> + currentProjectId?: number } -export function PqTableToolbarActions({ table}: DocTableToolbarActionsProps) { - - +export function PqTableToolbarActions({ + table, + currentProjectId +}: PqTableToolbarActionsProps) { + const [refreshKey, setRefreshKey] = React.useState(0) + const isProjectSpecific = !!currentProjectId + + // Import 성공 후 테이블 갱신 + const handleImportSuccess = () => { + setRefreshKey(prev => prev + 1) + } + return ( <div className="flex items-center gap-2"> {table.getFilteredSelectedRowModel().rows.length > 0 ? ( @@ -29,27 +47,41 @@ export function PqTableToolbarActions({ table}: DocTableToolbarActionsProps) { onSuccess={() => table.toggleAllRowsSelected(false)} /> ) : null} - - - <AddPqDialog /> - - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "Document-list", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> - - - + + <AddPqDialog currentProjectId={currentProjectId} /> + + {/* Import 버튼 */} + <ImportPqButton + projectId={currentProjectId} + 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: isProjectSpecific ? `project-${currentProjectId}-pq-criteria` : "general-pq-criteria", + excludeColumns: ["select", "actions"], + }) + } + > + <FileDown className="mr-2 h-4 w-4" /> + <span>현재 데이터 내보내기</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => exportPqTemplate(isProjectSpecific)}> + <FileDown className="mr-2 h-4 w-4" /> + <span>{isProjectSpecific ? '프로젝트용' : '일반'} 템플릿 다운로드</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> ) }
\ No newline at end of file diff --git a/lib/pq/table/pq-table.tsx b/lib/pq/table/pq-table.tsx index 73876c72..99365ad5 100644 --- a/lib/pq/table/pq-table.tsx +++ b/lib/pq/table/pq-table.tsx @@ -19,10 +19,12 @@ import { UpdatePqSheet } from "./update-pq-sheet" interface DocumentListTableProps { promises: Promise<[Awaited<ReturnType<typeof getPQs>>]> + currentProjectId?: number } export function PqsTable({ promises, + currentProjectId }: DocumentListTableProps) { // 1) 데이터를 가져옴 (server component -> use(...) pattern) const [{ data, pageCount }] = React.use(promises) @@ -103,7 +105,7 @@ export function PqsTable({ filterFields={advancedFilterFields} shallow={false} > - <PqTableToolbarActions table={table} /> + <PqTableToolbarActions table={table} currentProjectId={currentProjectId}/> </DataTableAdvancedToolbar> </DataTable> |
