diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:39:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:39:21 +0000 |
| commit | 53ad72732f781e6c6d5ddb3776ea47aec010af8e (patch) | |
| tree | e676287827f8634be767a674b8ad08b6ed7eb3e6 /lib/pq/table | |
| parent | 3e4d15271322397764601dee09441af8a5b3adf5 (diff) | |
(최겸) PQ/실사 수정 및 개발
Diffstat (limited to 'lib/pq/table')
| -rw-r--r-- | lib/pq/table/add-pq-dialog.tsx | 454 | ||||
| -rw-r--r-- | lib/pq/table/add-pq-list-dialog.tsx | 231 | ||||
| -rw-r--r-- | lib/pq/table/copy-pq-list-dialog.tsx | 244 | ||||
| -rw-r--r-- | lib/pq/table/delete-pq-list-dialog.tsx (renamed from lib/pq/table/delete-pqs-dialog.tsx) | 60 | ||||
| -rw-r--r-- | lib/pq/table/import-pq-button.tsx | 270 | ||||
| -rw-r--r-- | lib/pq/table/import-pq-handler.tsx | 145 | ||||
| -rw-r--r-- | lib/pq/table/pq-excel-template.tsx | 205 | ||||
| -rw-r--r-- | lib/pq/table/pq-lists-columns.tsx | 216 | ||||
| -rw-r--r-- | lib/pq/table/pq-lists-table.tsx | 170 | ||||
| -rw-r--r-- | lib/pq/table/pq-lists-toolbar.tsx | 61 | ||||
| -rw-r--r-- | lib/pq/table/pq-table-column.tsx | 185 | ||||
| -rw-r--r-- | lib/pq/table/pq-table-toolbar-actions.tsx | 87 | ||||
| -rw-r--r-- | lib/pq/table/pq-table.tsx | 127 | ||||
| -rw-r--r-- | lib/pq/table/update-pq-sheet.tsx | 264 |
14 files changed, 947 insertions, 1772 deletions
diff --git a/lib/pq/table/add-pq-dialog.tsx b/lib/pq/table/add-pq-dialog.tsx deleted file mode 100644 index 2fac9e43..00000000 --- a/lib/pq/table/add-pq-dialog.tsx +++ /dev/null @@ -1,454 +0,0 @@ -"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 { 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({ - 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(), - // 프로젝트별 PQ 여부 체크박스 - isProjectSpecific: z.boolean().default(false), - // 프로젝트 관련 추가 필드는 isProjectSpecific가 true일 때만 필수 - contractInfo: z.string().optional(), - additionalRequirement: z.string().optional(), -}); - -type CreatePqFormType = z.infer<typeof createPqSchema>; - -// 그룹 이름 옵션 -export const groupOptions = [ - "GENERAL", - "Quality Management System", - "Workshop & Environment", - "Warranty", -]; - -// 설명 예시 텍스트 -const descriptionExample = `Address : -Tel. / Fax : -e-mail :`; - -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<CreatePqFormType>({ - resolver: zodResolver(createPqSchema), - defaultValues: { - code: "", - checkPoint: "", - groupName: groupOptions[0], - description: "", - 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: 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(submitData) - - if (!result.success) { - toast({ - title: "Error", - description: result.message || "Failed to create PQ criteria", - variant: "destructive", - }) - return - } - - await invalidatePqCache(); - - // 성공 시 처리 - toast({ - title: "Success", - 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({ - title: "Error", - description: "An unexpected error occurred", - variant: "destructive", - }) - } finally { - setIsSubmitting(false) - } - } - - 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}> - {/* 모달을 열기 위한 버튼 */} - <DialogTrigger asChild> - <Button variant="default" size="sm"> - <Plus className="size-4" /> - Add PQ - </Button> - </DialogTrigger> - - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle>Create New PQ Criteria</DialogTitle> - <DialogDescription> - 새 PQ 기준 정보를 입력하고 <b>Create</b> 버튼을 누르세요. - </DialogDescription> - </DialogHeader> - - {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} - <Form {...form}> - <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> - <Checkbox - checked={field.value} - onCheckedChange={field.onChange} - /> - </FormControl> - <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 항목을 적용할 프로젝트를 선택하세요 - </FormDescription> - </div> - )} - - <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> - )} - /> - - {/* 추가 요구사항 필드 */} - <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> - )} - /> - </> - )} - </div> - - </div> - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => { - form.reset(); - setSelectedProject(null); - setOpen(false); - }} - > - Cancel - </Button> - <Button - type="submit" - disabled={isSubmitting} - > - {isSubmitting ? "Creating..." : "Create"} - </Button> - </DialogFooter> - </form> - </Form> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/pq/table/add-pq-list-dialog.tsx b/lib/pq/table/add-pq-list-dialog.tsx new file mode 100644 index 00000000..c1899a29 --- /dev/null +++ b/lib/pq/table/add-pq-list-dialog.tsx @@ -0,0 +1,231 @@ +"use client"
+
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { DatePicker } from "@/components/ui/date-picker"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { Loader2, Plus } from "lucide-react"
+
+// 프로젝트 목록을 위한 임시 타입 (실제로는 projects에서 가져와야 함)
+interface Project {
+ id: number
+ name: string
+ code: string
+}
+
+const pqListFormSchema = z.object({
+ name: z.string().min(1, "PQ 목록 명을 입력해주세요"),
+ type: z.enum(["GENERAL", "PROJECT", "NON_INSPECTION"], {
+ required_error: "PQ 유형을 선택해주세요"
+ }),
+ projectId: z.number().optional().nullable(),
+ validTo: z.date().optional().nullable(),
+}).refine((data) => {
+ // 프로젝트 PQ인 경우 프로젝트 선택 필수
+ if (data.type === "PROJECT" && !data.projectId) {
+ return false
+ }
+ return true
+}, {
+ message: "프로젝트 PQ인 경우 프로젝트를 선택해야 합니다",
+ path: ["projectId"]
+})
+
+type PqListFormData = z.infer<typeof pqListFormSchema>
+
+interface PqListFormProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ initialData?: Partial<PqListFormData> & { id?: number }
+ projects?: Project[]
+ onSubmit: (data: PqListFormData & { id?: number }) => Promise<void>
+ isLoading?: boolean
+}
+
+const typeLabels = {
+ GENERAL: "일반 PQ",
+ PROJECT: "프로젝트 PQ",
+ NON_INSPECTION: "미실사 PQ"
+}
+
+export function AddPqDialog({
+ open,
+ onOpenChange,
+ initialData,
+ projects = [],
+ onSubmit,
+ isLoading = false
+}: PqListFormProps) {
+ const isEditing = !!initialData?.id
+
+ const form = useForm<PqListFormData>({
+ resolver: zodResolver(pqListFormSchema),
+ defaultValues: {
+ name: initialData?.name || "",
+ type: initialData?.type || "GENERAL",
+ projectId: initialData?.projectId || null,
+ validTo: initialData?.validTo || undefined,
+ }
+ })
+
+ const selectedType = form.watch("type")
+ const formState = form.formState
+
+ const handleSubmit = async (data: PqListFormData) => {
+ try {
+ await onSubmit({
+ ...data,
+ id: initialData?.id
+ })
+ form.reset()
+ onOpenChange(false)
+ } catch (error) {
+ // 에러는 상위 컴포넌트에서 처리
+ console.error("Failed to submit PQ list:", error)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Plus className="h-5 w-5" />
+ {isEditing ? "PQ 목록 수정" : "신규 PQ 목록 생성"}
+ </DialogTitle>
+ <DialogDescription>
+ {isEditing ? "PQ 목록 정보를 수정합니다." : "새로운 PQ 목록을 생성합니다."}
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* PQ 유형 */}
+ <FormField
+ control={form.control}
+ name="type"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ PQ 유형 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} defaultValue={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="PQ 유형을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {Object.entries(typeLabels).map(([value, label]) => (
+ <SelectItem key={value} value={value}>
+ {label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* PQ 목록 명 */}
+ <FormField
+ control={form.control}
+ name="name"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ PQ 리스트명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: General PQ v2.0, EPC-1234 Project PQ"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 (프로젝트 PQ인 경우만) */}
+ {selectedType === "PROJECT" && (
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 프로젝트 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ defaultValue={field.value?.toString()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="프로젝트를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.code} - {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 유효기간 (프로젝트 PQ인 경우) */}
+ {selectedType === "PROJECT" && (
+ <FormField
+ control={form.control}
+ name="validTo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 유효일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value ?? undefined}
+ onSelect={(date) => field.onChange(date ?? null)}
+ placeholder="유효일 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 버튼들 */}
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading || !formState.isValid}>
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isEditing ? "수정" : "생성"}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file diff --git a/lib/pq/table/copy-pq-list-dialog.tsx b/lib/pq/table/copy-pq-list-dialog.tsx new file mode 100644 index 00000000..647ab1a3 --- /dev/null +++ b/lib/pq/table/copy-pq-list-dialog.tsx @@ -0,0 +1,244 @@ +"use client"
+
+// import { useState } from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { Button } from "@/components/ui/button"
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
+import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
+import { DatePicker } from "@/components/ui/date-picker"
+import { Loader2, Copy } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Input } from "@/components/ui/input"
+// import { Card, CardContent } from "@/components/ui/card"
+
+interface PQList {
+ id: number
+ name: string
+ type: "GENERAL" | "PROJECT" | "NON_INSPECTION"
+ projectId?: number | null
+ criteriaCount?: number
+ createdAt: Date
+}
+
+interface Project {
+ id: number
+ name: string
+ code: string
+}
+
+const copyPqSchema = z.object({
+ sourcePqListId: z.number({
+ required_error: "복사할 PQ 목록을 선택해주세요"
+ }),
+ targetProjectId: z.number({
+ required_error: "대상 프로젝트를 선택해주세요"
+ }),
+ validTo: z.date(),
+ newName: z.string(),
+})
+
+type CopyPqFormData = z.infer<typeof copyPqSchema>
+
+interface CopyPqDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ pqLists: PQList[]
+ projects: Project[]
+ onCopy: (data: CopyPqFormData) => Promise<void>
+ isLoading?: boolean
+}
+
+const typeLabels = {
+ GENERAL: "일반 PQ",
+ PROJECT: "프로젝트 PQ",
+ NON_INSPECTION: "미실사 PQ"
+}
+
+const typeColors = {
+ GENERAL: "bg-blue-100 text-blue-800",
+ PROJECT: "bg-green-100 text-green-800",
+ NON_INSPECTION: "bg-orange-100 text-orange-800"
+}
+
+export function CopyPqDialog({
+ open,
+ onOpenChange,
+ pqLists,
+ projects,
+ onCopy,
+ isLoading = false
+}: CopyPqDialogProps) {
+ const form = useForm<CopyPqFormData>({
+ resolver: zodResolver(copyPqSchema),
+ })
+ const formState = form.formState
+
+ const selectedSourceId = form.watch("sourcePqListId")
+ const selectedPqList = pqLists.find(list => list.id === selectedSourceId)
+
+ const handleSubmit = async (data: CopyPqFormData) => {
+ try {
+ await onCopy(data)
+ form.reset()
+ onOpenChange(false)
+ } catch (error) {
+ // 에러는 상위 컴포넌트에서 처리
+ console.error("Failed to copy PQ list:", error)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Copy className="h-5 w-5" />
+ PQ 목록 불러오기
+ </DialogTitle>
+ <DialogDescription>
+ 기존 PQ 목록을 선택하여 새로운 프로젝트 PQ를 생성합니다.
+ 선택한 PQ의 모든 항목이 새 프로젝트로 복사됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* 대상 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="targetProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 대상 프로젝트 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ defaultValue={field.value?.toString()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="PQ를 적용할 프로젝트를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.code} - {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* 복사할 PQ 목록 선택 */}
+ <FormField
+ control={form.control}
+ name="sourcePqListId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 복사할 PQ 리스트 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ defaultValue={field.value?.toString()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="복사할 PQ 리스트를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {pqLists.map((pqList) => (
+ <SelectItem key={pqList.id} value={pqList.id.toString()}>
+ <div className="flex items-center gap-2">
+ <Badge className={typeColors[pqList.type]}>
+ {typeLabels[pqList.type]}
+ </Badge>
+ <span>{pqList.name}</span>
+ {pqList.criteriaCount && (
+ <span className="text-xs text-muted-foreground">
+ ({pqList.criteriaCount}개 항목)
+ </span>
+ )}
+ </div>
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ {selectedPqList && (
+ <div className="text-sm text-muted-foreground mt-1">
+ 선택된 PQ 리스트: <strong>{selectedPqList.name}</strong>
+ {selectedPqList.criteriaCount && (
+ <span> ({selectedPqList.criteriaCount}개 항목)</span>
+ )}
+ </div>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* 새 PQ 목록 명 */}
+ <FormField
+ control={form.control}
+ name="newName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 새 PQ 리스트명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input {...field} value={field.value ?? ""} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ {/* 유효기간 설정 */}
+ <FormField
+ control={form.control}
+ name="validTo"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 유효기간 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <DatePicker
+ date={field.value ?? undefined}
+ onSelect={(date) => field.onChange(date ?? null)}
+ placeholder="유효기간 선택"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 버튼들 */}
+ <div className="flex justify-end space-x-2">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isLoading || !formState.isValid}>
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 복사하여 생성
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file diff --git a/lib/pq/table/delete-pqs-dialog.tsx b/lib/pq/table/delete-pq-list-dialog.tsx index c6a2ce82..c9a6eb09 100644 --- a/lib/pq/table/delete-pqs-dialog.tsx +++ b/lib/pq/table/delete-pq-list-dialog.tsx @@ -3,7 +3,6 @@ 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" @@ -27,39 +26,28 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer" -import { PqCriterias } from "@/db/schema/pq" -import { removePqs } from "../service" +import type { PQList } from "./pq-lists-columns" - -interface DeleteTasksDialogProps +interface DeletePqListsDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { - pqs: Row<PqCriterias>["original"][] + pqLists: Row<PQList>["original"][] showTrigger?: boolean onSuccess?: () => void } -export function DeletePqsDialog({ - pqs, +export function DeletePqListsDialog({ + pqLists, showTrigger = true, onSuccess, ...props -}: DeleteTasksDialogProps) { +}: DeletePqListsDialogProps) { const [isDeletePending, startDeleteTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") function onDelete() { startDeleteTransition(async () => { - const { error } = await removePqs({ - ids: pqs.map((pq) => pq.id), - }) - - if (error) { - toast.error(error) - return - } - + // 상위 컴포넌트에서 삭제 로직을 처리하도록 콜백 호출 props.onOpenChange?.(false) - toast.success("Tasks deleted") onSuccess?.() }) } @@ -69,24 +57,25 @@ export function DeletePqsDialog({ <Dialog {...props}> {showTrigger ? ( <DialogTrigger asChild> - <Button variant="outline" size="sm"> + <Button variant="destructive" size="sm"> <Trash className="mr-2 size-4" aria-hidden="true" /> - Delete ({pqs.length}) + 삭제 ({pqLists.length}) </Button> </DialogTrigger> ) : null} <DialogContent> <DialogHeader> - <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> <DialogDescription> - This action cannot be undone. This will permanently delete your{" "} - <span className="font-medium">{pqs.length}</span> - {pqs.length === 1 ? " PQ" : " PQs"} from our servers. + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + <span className="font-medium">{pqLists.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">Cancel</Button> + <Button variant="outline">취소</Button> </DialogClose> <Button aria-label="Delete selected rows" @@ -100,7 +89,7 @@ export function DeletePqsDialog({ aria-hidden="true" /> )} - Delete + 삭제 </Button> </DialogFooter> </DialogContent> @@ -112,24 +101,25 @@ export function DeletePqsDialog({ <Drawer {...props}> {showTrigger ? ( <DrawerTrigger asChild> - <Button variant="outline" size="sm"> + <Button variant="destructive" size="sm"> <Trash className="mr-2 size-4" aria-hidden="true" /> - Delete ({pqs.length}) + 삭제 ({pqLists.length}) </Button> </DrawerTrigger> ) : null} <DrawerContent> <DrawerHeader> - <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> <DrawerDescription> - This action cannot be undone. This will permanently delete your{" "} - <span className="font-medium">{pqs.length}</span> - {pqs.length === 1 ? " task" : " pqs"} from our servers. + 이 작업은 되돌릴 수 없습니다. 선택한{" "} + <span className="font-medium">{pqLists.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">Cancel</Button> + <Button variant="outline">취소</Button> </DrawerClose> <Button aria-label="Delete selected rows" @@ -140,7 +130,7 @@ export function DeletePqsDialog({ {isDeletePending && ( <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> )} - Delete + 삭제 </Button> </DrawerFooter> </DrawerContent> diff --git a/lib/pq/table/import-pq-button.tsx b/lib/pq/table/import-pq-button.tsx deleted file mode 100644 index 2fbf66d9..00000000 --- a/lib/pq/table/import-pq-button.tsx +++ /dev/null @@ -1,270 +0,0 @@ -"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/table/import-pq-handler.tsx b/lib/pq/table/import-pq-handler.tsx deleted file mode 100644 index 13431ba7..00000000 --- a/lib/pq/table/import-pq-handler.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"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() ?? "", - 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 createPq({ - ...cleanedRow, - projectId: projectId || 0 - }); - - 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 deleted file mode 100644 index aa8c1b3a..00000000 --- a/lib/pq/table/pq-excel-template.tsx +++ /dev/null @@ -1,205 +0,0 @@ -"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-lists-columns.tsx b/lib/pq/table/pq-lists-columns.tsx new file mode 100644 index 00000000..1c401fac --- /dev/null +++ b/lib/pq/table/pq-lists-columns.tsx @@ -0,0 +1,216 @@ +"use client"
+
+import { ColumnDef } from "@tanstack/react-table"
+import { Badge } from "@/components/ui/badge"
+// import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
+import { DataTableRowAction } from "@/types/table"
+import { Ellipsis } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu"
+import { Button } from "@/components/ui/button"
+import React from "react"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Checkbox } from "@/components/ui/checkbox"
+
+export interface PQList {
+ id: number
+ name: string
+ type: "GENERAL" | "PROJECT" | "NON_INSPECTION"
+ projectId?: number | null
+ projectCode?: string | null
+ projectName?: string | null
+ isDeleted: boolean
+ validTo?: Date | null
+ createdBy?: string | null // 이제 사용자 이름(users.name)
+ createdAt: Date
+ updatedAt: Date
+ updatedBy?: string | null
+ criteriaCount?: number
+}
+
+const typeLabels = {
+ GENERAL: "일반 PQ",
+ PROJECT: "프로젝트 PQ",
+ NON_INSPECTION: "미실사 PQ"
+}
+
+const typeColors = {
+ GENERAL: "bg-blue-100 text-blue-800",
+ PROJECT: "bg-green-100 text-green-800",
+ NON_INSPECTION: "bg-orange-100 text-orange-800"
+}
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PQList> | null>>
+}
+export function createPQListsColumns({
+ setRowAction
+}: GetColumnsProps): ColumnDef<PQList>[] {
+ 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: "name",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 리스트명" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-[200px] truncate font-medium">
+ {row.getValue("name")}
+ </div>
+ ),
+ },
+ {
+ accessorKey: "type",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="PQ 종류" />
+ ),
+ cell: ({ row }) => {
+ const type = row.getValue("type") as keyof typeof typeLabels
+ return (
+ <Badge className={typeColors[type]}>
+ {typeLabels[type]}
+ </Badge>
+ )
+ },
+ filterFn: (row, id, value) => {
+ return value.includes(row.getValue(id))
+ },
+ },
+ {
+ accessorKey: "projectCode",
+ header: "프로젝트",
+ cell: ({ row }) => row.original.projectCode ?? "-",
+ },
+ {
+ accessorKey: "projectName",
+ header: "프로젝트명",
+ cell: ({ row }) => row.original.projectName ?? "-",
+ },
+ {
+ accessorKey: "validTo",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="유효일" />
+ ),
+ cell: ({ row }) => {
+ const validTo = row.getValue("validTo") as Date | null
+ const now = new Date()
+ const isExpired = validTo && validTo < now
+
+ const formattedDate = validTo ? formatDate(validTo, "ko-KR") : "-"
+
+ return (
+ <div className="text-sm">
+ <span className={isExpired ? "text-red-600 font-medium" : ""}>
+ {formattedDate}
+ </span>
+ {isExpired && (
+ <Badge variant="destructive" className="ml-2 text-xs">
+ 만료
+ </Badge>
+ )}
+ </div>
+ )
+ },
+ },
+ {
+ accessorKey: "isDeleted",
+ header: "상태",
+ cell: ({ row }) => {
+ const isDeleted = row.getValue("isDeleted") as boolean;
+ return (
+ <Badge variant={isDeleted ? "destructive" : "success"}>
+ {isDeleted ? "비활성" : "활성"}
+ </Badge>
+ );
+ },
+ },
+ {
+ accessorKey: "createdBy",
+ header: "생성자",
+ cell: ({ row }) => row.original.createdBy ?? "-",
+ },
+ {
+ accessorKey: "updatedBy",
+ header: "변경자",
+ cell: ({ row }) => row.original.updatedBy ?? "-",
+ },
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="생성일" />
+ ),
+ cell: ({ row }) => formatDate(row.getValue("createdAt"), "ko-KR"),
+ },
+ {
+ accessorKey: "updatedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="변경일" />
+ ),
+ cell: ({ row }) => formatDate(row.getValue("updatedAt"), "ko-KR"),
+ },
+ {
+ id: "actions",
+ cell: ({ row }) => (
+ <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: "view" })}
+ >
+ 상세보기
+ </DropdownMenuItem>
+ {/* <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ className="text-destructive"
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem> */}
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size: 40,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ ]
+}
\ No newline at end of file diff --git a/lib/pq/table/pq-lists-table.tsx b/lib/pq/table/pq-lists-table.tsx new file mode 100644 index 00000000..c5fd82a5 --- /dev/null +++ b/lib/pq/table/pq-lists-table.tsx @@ -0,0 +1,170 @@ +"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { toast } from "sonner"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { createPQListsColumns, type PQList } from "./pq-lists-columns"
+import {
+ createPQListAction,
+ deletePQListsAction,
+ copyPQListAction,
+ togglePQListsAction,
+} from "@/lib/pq/service"
+import { CopyPqDialog } from "./copy-pq-list-dialog"
+import { AddPqDialog } from "./add-pq-list-dialog"
+import { PQListsToolbarActions } from "./pq-lists-toolbar"
+import type { DataTableRowAction } from "@/types/table"
+
+interface Project {
+ id: number
+ name: string
+ code: string
+}
+
+interface PqListsTableProps {
+ promises: Promise<[{ data: PQList[]; pageCount: number }, Project[]]>
+}
+
+export function PqListsTable({ promises }: PqListsTableProps) {
+ const router = useRouter()
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<PQList> | null>(null)
+ const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
+ const [copyDialogOpen, setCopyDialogOpen] = React.useState(false)
+ const [isPending, startTransition] = React.useTransition()
+
+ const [{ data, pageCount }, projects] = React.use(promises)
+ const activePqLists = data.filter((item) => !item.isDeleted)
+
+ const columns = React.useMemo(() => createPQListsColumns({ setRowAction }), [setRowAction])
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ enablePinning: true,
+ enableAdvancedFilter: false,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (row) => String(row.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ const handleCreate = async (formData: {
+ name: string
+ type: "GENERAL" | "PROJECT" | "NON_INSPECTION"
+ projectId?: number | null
+ validTo?: Date | null
+ }) => {
+ startTransition(async () => {
+ const result = await createPQListAction(formData)
+ if (result.success) {
+ toast.success("PQ 목록이 생성되었습니다")
+ setCreateDialogOpen(false)
+ router.refresh()
+ } else {
+ toast.error(result.error || "PQ 목록 생성 실패")
+ }
+ })
+ }
+
+ const handleToggleActive = async (ids: number[], newIsDeleted: boolean) => {
+ startTransition(async () => {
+ const result = await togglePQListsAction(ids, newIsDeleted)
+ if (result.success) {
+ toast.success(newIsDeleted ? "PQ 목록이 비활성화되었습니다" : "PQ 목록이 활성화되었습니다")
+ router.refresh()
+ } else {
+ toast.error("PQ 목록 상태 변경 실패")
+ }
+ })
+ }
+
+ const handleDelete = async (ids: number[]) => {
+ startTransition(async () => {
+ const result = await deletePQListsAction(ids)
+ if (result.success) {
+ toast.success("PQ 목록이 삭제되었습니다")
+ router.refresh()
+ } else {
+ toast.error("PQ 목록 삭제 실패")
+ }
+ })
+ }
+
+ const handleCopy = async (copyData: {
+ sourcePqListId: number
+ targetProjectId: number
+ newName?: string
+ validTo?: Date | null
+ }) => {
+ startTransition(async () => {
+ const result = await copyPQListAction(copyData)
+ if (result.success) {
+ toast.success("PQ 목록이 복사되었습니다")
+ setCopyDialogOpen(false)
+ router.refresh()
+ } else {
+ toast.error("PQ 목록 복사 실패")
+ }
+ })
+ }
+
+ React.useEffect(() => {
+ if (!rowAction) return
+ const id = rowAction.row.original.id
+ switch (rowAction.type) {
+ case "view":
+ router.push(`/evcp/pq-criteria/${id}`)
+ break
+ case "delete":
+ handleDelete([id])
+ break
+ }
+ setRowAction(null)
+ }, [rowAction])
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={[]}
+ shallow={false}
+ >
+ <PQListsToolbarActions
+ table={table}
+ onAddClick={() => setCreateDialogOpen(true)}
+ onCopyClick={() => setCopyDialogOpen(true)}
+ onToggleActive={(rows, newIsDeleted) =>
+ handleToggleActive(rows.map((r) => r.id), newIsDeleted)
+ }
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ <AddPqDialog
+ open={createDialogOpen}
+ onOpenChange={setCreateDialogOpen}
+ onSubmit={handleCreate}
+ projects={projects}
+ isLoading={isPending}
+ />
+
+ <CopyPqDialog
+ open={copyDialogOpen}
+ onOpenChange={setCopyDialogOpen}
+ pqLists={activePqLists}
+ projects={projects}
+ onCopy={handleCopy}
+ isLoading={isPending}
+ />
+ </>
+ )
+}
diff --git a/lib/pq/table/pq-lists-toolbar.tsx b/lib/pq/table/pq-lists-toolbar.tsx new file mode 100644 index 00000000..3a85327d --- /dev/null +++ b/lib/pq/table/pq-lists-toolbar.tsx @@ -0,0 +1,61 @@ +"use client"
+
+import * as React from "react"
+import { Button } from "@/components/ui/button"
+import { Trash, CopyPlus, Plus } from "lucide-react"
+import { type Table } from "@tanstack/react-table"
+import type { PQList } from "./pq-lists-columns"
+// import { PqListForm } from "./add-pq-list-dialog"
+
+interface PQListsToolbarActionsProps {
+ table: Table<PQList>;
+ onAddClick: () => void;
+ onCopyClick: () => void;
+ onToggleActive: (rows: PQList[], newIsDeleted: boolean) => void;
+}
+
+export function PQListsToolbarActions({
+ table,
+ onAddClick,
+ onCopyClick,
+ onToggleActive,
+}: PQListsToolbarActionsProps) {
+ const selected = table.getFilteredSelectedRowModel().rows.map(r => r.original);
+ const allActive = selected.length > 0 && selected.every(item => !item.isDeleted);
+ const allDeleted = selected.length > 0 && selected.every(item => item.isDeleted);
+
+ let toggleLabel = "";
+ let newState: boolean | undefined;
+ if (selected.length > 0) {
+ if (allActive) {
+ toggleLabel = "비활성화";
+ newState = true;
+ } else if (allDeleted) {
+ toggleLabel = "활성화";
+ newState = false;
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {selected.length > 0 && (allActive || allDeleted) && newState !== undefined && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => onToggleActive(selected, newState!)}
+ >
+ <Trash className="mr-2 h-4 w-4" />
+ {toggleLabel}
+ </Button>
+ )}
+ <Button size="sm" onClick={onAddClick}>
+ <Plus className="mr-2 h-4 w-4" />
+ 추가
+ </Button>
+ <Button size="sm" variant="outline" onClick={onCopyClick}>
+ <CopyPlus className="mr-2 h-4 w-4" />
+ 복사
+ </Button>
+ </div>
+ );
+}
diff --git a/lib/pq/table/pq-table-column.tsx b/lib/pq/table/pq-table-column.tsx deleted file mode 100644 index b9317570..00000000 --- a/lib/pq/table/pq-table-column.tsx +++ /dev/null @@ -1,185 +0,0 @@ -"use client" - -import * as React from "react" -import { ColumnDef } from "@tanstack/react-table" -import { formatDate, formatDateTime } 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, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - 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="Group Name" /> - ), - cell: ({ row }) => <div>{row.getValue("groupName")}</div>, - meta: { - excelHeader: "Group Name" - }, - enableResizing: true, - minSize: 60, - size: 100, - }, - { - accessorKey: "code", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Code" /> - ), - cell: ({ row }) => <div>{row.getValue("code")}</div>, - meta: { - excelHeader: "Code" - }, - enableResizing: true, - minSize: 50, - size: 100, - }, - { - accessorKey: "checkPoint", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Check Point" /> - ), - 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="Description" /> - ), - 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: "createdAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Created At" /> - ), - cell: ({ cell }) => formatDateTime(cell.getValue() as Date, "KR"), - meta: { - excelHeader: "created At" - }, - enableResizing: true, - minSize: 180, - size: 180, - }, - { - accessorKey: "updatedAt", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Updated At" /> - ), - cell: ({ cell }) => formatDateTime(cell.getValue() as Date, "KR"), - meta: { - excelHeader: "updated At" - }, - enableResizing: true, - minSize: 180, - size: 180, - }, - { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - 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/table/pq-table-toolbar-actions.tsx b/lib/pq/table/pq-table-toolbar-actions.tsx deleted file mode 100644 index 1790caf8..00000000 --- a/lib/pq/table/pq-table-toolbar-actions.tsx +++ /dev/null @@ -1,87 +0,0 @@ -"use client" - -import * as React from "react" -import { type Table } from "@tanstack/react-table" -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 PqTableToolbarActionsProps { - table: Table<PqCriterias> - currentProjectId?: number -} - -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 ? ( - <DeletePqsDialog - pqs={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} - - <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 deleted file mode 100644 index 99365ad5..00000000 --- a/lib/pq/table/pq-table.tsx +++ /dev/null @@ -1,127 +0,0 @@ -"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 { getPQs } 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 getPQs>>]> - currentProjectId?: number -} - -export function PqsTable({ - promises, - currentProjectId -}: 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} currentProjectId={currentProjectId}/> - </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/table/update-pq-sheet.tsx b/lib/pq/table/update-pq-sheet.tsx deleted file mode 100644 index 4da3c264..00000000 --- a/lib/pq/table/update-pq-sheet.tsx +++ /dev/null @@ -1,264 +0,0 @@ -"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 { modifyPq } from "../service" -import { groupOptions } from "./add-pq-dialog" - -// 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() -}); - -type UpdatePqSchema = z.infer<typeof updatePqSchema>; - -interface UpdatePqSheetProps - extends React.ComponentPropsWithRef<typeof Sheet> { - pq: { - id: number; - code: string; - checkPoint: string; - description: string | null; - remarks: string | null; - groupName: 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 ?? "", - }, - }) - - // 폼 초기화 (pq가 변경될 때) - React.useEffect(() => { - if (pq) { - form.reset({ - code: pq.code, - checkPoint: pq.checkPoint, - groupName: pq.groupName ?? groupOptions[0], - description: pq.description ?? "", - remarks: pq.remarks ?? "", - }); - } - }, [pq, form]); - - function onSubmit(input: UpdatePqSchema) { - startUpdateTransition(async () => { - if (!pq) return - - const result = await modifyPq({ - id: pq.id, - ...input, - }) - - if (!result.success && 'error' in result) { - toast.error(result.error) - } else { - toast.error("Failed to update PQ criteria") - } - - form.reset() - props.onOpenChange?.(false) - toast.success("PQ criteria updated successfully") - 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> - )} - /> - - {/* 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 |
