summaryrefslogtreecommitdiff
path: root/lib/pq/table
diff options
context:
space:
mode:
Diffstat (limited to 'lib/pq/table')
-rw-r--r--lib/pq/table/add-pq-dialog.tsx431
-rw-r--r--lib/pq/table/import-pq-button.tsx258
-rw-r--r--lib/pq/table/import-pq-handler.tsx146
-rw-r--r--lib/pq/table/pq-excel-template.tsx205
-rw-r--r--lib/pq/table/pq-table-toolbar-actions.tsx86
-rw-r--r--lib/pq/table/pq-table.tsx4
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>