diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-18 00:23:40 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-09-18 00:23:40 +0000 |
| commit | cf8dac0c6490469dab88a560004b0c07dbd48612 (patch) | |
| tree | b9e76061e80d868331e6b4277deecb9086f845f3 /lib/itb/table | |
| parent | e5745fc0268bbb5770bc14a55fd58a0ec30b466e (diff) | |
(대표님) rfq, 계약, 서명 등
Diffstat (limited to 'lib/itb/table')
| -rw-r--r-- | lib/itb/table/approve-purchase-request-dialog.tsx | 0 | ||||
| -rw-r--r-- | lib/itb/table/create-purchase-request-dialog.tsx | 995 | ||||
| -rw-r--r-- | lib/itb/table/create-rfq-dialog.tsx | 380 | ||||
| -rw-r--r-- | lib/itb/table/delete-purchase-request-dialog.tsx | 225 | ||||
| -rw-r--r-- | lib/itb/table/edit-purchase-request-sheet.tsx | 1081 | ||||
| -rw-r--r-- | lib/itb/table/items-dialog.tsx | 167 | ||||
| -rw-r--r-- | lib/itb/table/purchase-request-columns.tsx | 380 | ||||
| -rw-r--r-- | lib/itb/table/purchase-requests-table.tsx | 229 | ||||
| -rw-r--r-- | lib/itb/table/view-purchase-request-sheet.tsx | 809 |
9 files changed, 4266 insertions, 0 deletions
diff --git a/lib/itb/table/approve-purchase-request-dialog.tsx b/lib/itb/table/approve-purchase-request-dialog.tsx new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/lib/itb/table/approve-purchase-request-dialog.tsx diff --git a/lib/itb/table/create-purchase-request-dialog.tsx b/lib/itb/table/create-purchase-request-dialog.tsx new file mode 100644 index 00000000..27b8a342 --- /dev/null +++ b/lib/itb/table/create-purchase-request-dialog.tsx @@ -0,0 +1,995 @@ +// components/purchase-requests/create-purchase-request-dialog.tsx +"use client"; + +import * as React from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ProjectSelector } from "@/components/ProjectSelector"; +import { PackageSelector } from "@/components/PackageSelector"; +import type { PackageItem } from "@/lib/items/service"; +import { MaterialGroupSelector } from "@/components/common/material/material-group-selector"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { Progress } from "@/components/ui/progress"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { CalendarIcon, X, Plus, FileText, Package, Trash2, Upload, FileIcon, AlertCircle, Paperclip } from "lucide-react"; +import { format } from "date-fns"; +import { cn } from "@/lib/utils"; +import { createPurchaseRequest } from "../service"; +import { toast } from "sonner"; +import { nanoid } from "nanoid"; + +const attachmentSchema = z.object({ + fileName: z.string(), + originalFileName: z.string(), + filePath: z.string(), + fileSize: z.number(), + fileType: z.string(), +}); + +const itemSchema = z.object({ + id: z.string(), + itemCode: z.string(), + itemName: z.string().min(1, "아이템명을 입력해주세요"), + specification: z.string().default(""), + quantity: z.coerce.number().min(1, "수량은 1 이상이어야 합니다"), + unit: z.string().min(1, "단위를 입력해주세요"), + estimatedUnitPrice: z.coerce.number().optional(), + remarks: z.string().optional(), +}); + +const formSchema = z.object({ + requestTitle: z.string().min(1, "요청 제목을 입력해주세요"), + requestDescription: z.string().optional(), + projectId: z.number({ + required_error: "프로젝트를 선택해주세요", + }), + projectCode: z.string().min(1), + projectName: z.string().min(1), + projectCompany: z.string().optional(), + projectSite: z.string().optional(), + classNo: z.string().optional(), + packageNo: z.string({ + required_error: "패키지를 선택해주세요", + }).min(1, "패키지를 선택해주세요"), + packageName: z.string().min(1), + majorItemMaterialCategory: z.string({ + required_error: "자재그룹을 선택해주세요", + }).min(1, "자재그룹을 선택해주세요"), + majorItemMaterialDescription: z.string().min(1), + smCode: z.string({ + required_error: "SM 코드가 필요합니다", + }).min(1, "SM 코드가 필요합니다"), + estimatedBudget: z.string().optional(), + requestedDeliveryDate: z.date().optional(), + items: z.array(itemSchema).optional().default([]), + attachments: z.array(attachmentSchema).min(1, "최소 1개 이상의 파일을 첨부해주세요"), +}); + +type FormData = z.infer<typeof formSchema>; + +interface CreatePurchaseRequestDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function CreatePurchaseRequestDialog({ + open, + onOpenChange, + onSuccess, +}: CreatePurchaseRequestDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [activeTab, setActiveTab] = React.useState("basic"); + const [selectedPackage, setSelectedPackage] = React.useState<PackageItem | null>(null); + const [selectedMaterials, setSelectedMaterials] = React.useState<any[]>([]); + const [resetKey, setResetKey] = React.useState(0); + const [uploadedFiles, setUploadedFiles] = React.useState<File[]>([]); + const [isUploading, setIsUploading] = React.useState(false); + const [uploadProgress, setUploadProgress] = React.useState(0); + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + mode: "onChange", + criteriaMode: "all", + defaultValues: { + requestTitle: "", + requestDescription: "", + projectId: undefined, + projectCode: "", + projectName: "", + projectCompany: "", + projectSite: "", + classNo: "", + packageNo: "", + packageName: "", + majorItemMaterialCategory: "", + majorItemMaterialDescription: "", + smCode: "", + estimatedBudget: "", + requestedDeliveryDate: undefined, + items: [], + attachments: [], + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }); + + // 파일 업로드 핸들러 + const handleFileUpload = async (files: File[]) => { + if (files.length === 0) return; + + setIsUploading(true); + setUploadProgress(0); + + try { + const formData = new FormData(); + files.forEach(file => { + formData.append("files", file); + }); + + // 프로그레스 시뮬레이션 (실제로는 XMLHttpRequest 또는 fetch with progress를 사용) + const progressInterval = setInterval(() => { + setUploadProgress(prev => Math.min(prev + 10, 90)); + }, 200); + + const response = await fetch("/api/upload/purchase-request", { + method: "POST", + body: formData, + }); + + clearInterval(progressInterval); + setUploadProgress(100); + + if (!response.ok) { + throw new Error("파일 업로드 실패"); + } + + const data = await response.json(); + + // 업로드된 파일 정보를 상태에 추가 + const newFiles = [...uploadedFiles, ...data.files]; + setUploadedFiles(newFiles); + + // form의 attachments 필드 업데이트 + form.setValue("attachments", newFiles); + + toast.success(`${files.length}개 파일이 업로드되었습니다`); + } catch (error) { + console.error("Upload error:", error); + toast.error("파일 업로드 중 오류가 발생했습니다"); + } finally { + setIsUploading(false); + setUploadProgress(0); + } + }; + + // 파일 삭제 핸들러 + const handleFileRemove = (fileName: string) => { + const updatedFiles = uploadedFiles.filter(f => f.fileName !== fileName); + setUploadedFiles(updatedFiles); + form.setValue("attachments", updatedFiles); + }; + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: any) => { + form.setValue("projectId", project.id); + form.setValue("projectCode", project.projectCode); + form.setValue("projectName", project.projectName); + form.setValue("projectCompany", project.projectCompany); + form.setValue("projectSite", project.projectSite); + + // 프로젝트 변경시 패키지 초기화 + setSelectedPackage(null); + form.setValue("packageNo", ""); + form.setValue("packageName", ""); + form.setValue("smCode", ""); + + // requestTitle 업데이트 + updateRequestTitle(project.projectName, "", ""); + }; + + // 패키지 선택 처리 + const handlePackageSelect = (packageItem: PackageItem) => { + setSelectedPackage(packageItem); + form.setValue("packageNo", packageItem.packageCode); + form.setValue("packageName", packageItem.description); + + // SM 코드가 패키지에 있으면 자동 설정 + if (packageItem.smCode) { + form.setValue("smCode", packageItem.smCode); + } + + // requestTitle 업데이트 + const projectName = form.getValues("projectName"); + const materialDesc = form.getValues("majorItemMaterialDescription"); + updateRequestTitle(projectName, packageItem.description, materialDesc); + }; + + // 자재그룹 선택 처리 + const handleMaterialsChange = (materials: any[]) => { + setSelectedMaterials(materials); + if (materials.length > 0) { + const material = materials[0]; // single select이므로 첫번째 항목만 + form.setValue("majorItemMaterialCategory", material.materialGroupCode); + form.setValue("majorItemMaterialDescription", material.materialGroupDescription); + + // requestTitle 업데이트 + const projectName = form.getValues("projectName"); + const packageName = form.getValues("packageName"); + updateRequestTitle(projectName, packageName, material.materialGroupDescription); + } else { + form.setValue("majorItemMaterialCategory", ""); + form.setValue("majorItemMaterialDescription", ""); + } + }; + + // requestTitle 자동 생성 + const updateRequestTitle = (projectName: string, packageName: string, materialDesc: string) => { + const parts = []; + if (projectName) parts.push(projectName); + if (packageName) parts.push(packageName); + if (materialDesc) parts.push(materialDesc); + + if (parts.length > 0) { + const title = `${parts.join(" - ")} 구매 요청`; + form.setValue("requestTitle", title); + } + }; + + const addItem = () => { + append({ + id: nanoid(), + itemCode: "", + itemName: "", + specification: "", + quantity: 1, + unit: "개", + estimatedUnitPrice: undefined, + remarks: "", + }); + }; + + const calculateTotal = () => { + const items = form.watch("items"); + return items?.reduce((sum, item) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return sum + subtotal; + }, 0) || 0; + }; + + const onSubmit = async (values: FormData) => { + try { + setIsLoading(true); + + const result = await createPurchaseRequest({ + ...values, + items: values.items, + attachments: uploadedFiles, // 첨부파일 포함 + }); + + if (result.error) { + toast.error(result.error); + return; + } + + toast.success("구매 요청이 등록되었습니다"); + handleClose(); + onSuccess?.(); + } catch (error) { + toast.error("구매 요청 등록에 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + form.reset({ + requestTitle: "", + requestDescription: "", + projectId: undefined, + projectCode: "", + projectName: "", + projectCompany: "", + projectSite: "", + classNo: "", + packageNo: "", + packageName: "", + majorItemMaterialCategory: "", + majorItemMaterialDescription: "", + smCode: "", + estimatedBudget: "", + requestedDeliveryDate: undefined, + items: [], + attachments: [], + }); + setSelectedPackage(null); + setSelectedMaterials([]); + setUploadedFiles([]); + setActiveTab("basic"); + setResetKey((k) => k + 1); + onOpenChange(false); + }; + + return ( + <Dialog open={open} onOpenChange={handleClose}> + <DialogContent key={resetKey} className="max-w-5xl h-[85vh] overflow-hidden flex flex-col min-h-0"> + <DialogHeader> + <DialogTitle>새 구매 요청</DialogTitle> + <DialogDescription> + 구매가 필요한 품목과 관련 정보를 입력해주세요 + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 overflow-hidden flex flex-col min-h-0"> + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0"> + <TabsList className="grid w-full grid-cols-3 flex-shrink-0"> + <TabsTrigger value="basic"> + <FileText className="mr-2 h-4 w-4" /> + 기본 정보 + </TabsTrigger> + <TabsTrigger value="items"> + <Package className="mr-2 h-4 w-4" /> + 품목 정보 + {fields.length > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {fields.length} + </span> + )} + </TabsTrigger> + <TabsTrigger value="files"> + <Paperclip className="mr-2 h-4 w-4" /> + 첨부파일 + {uploadedFiles.length > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {uploadedFiles.length} + </span> + )} + </TabsTrigger> + </TabsList> + + <div className="flex-1 overflow-y-auto px-1 min-h-0"> + <TabsContent value="basic" className="space-y-6 mt-6"> + {/* 프로젝트 선택 */} + <Card> + <CardHeader> + <CardTitle className="text-base">프로젝트 정보 <span className="text-red-500">*</span></CardTitle> + <CardDescription>프로젝트, 패키지, 자재그룹을 순서대로 선택하세요</CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 프로젝트 선택 */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 <span className="text-red-500">*</span></FormLabel> + <ProjectSelector + key={`project-${resetKey}`} + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 검색하여 선택..." + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지 선택 */} + <FormField + control={form.control} + name="packageNo" + render={({ field }) => ( + <FormItem> + <FormLabel>패키지 <span className="text-red-500">*</span></FormLabel> + <PackageSelector + projectNo={form.watch("projectCode")} + selectedPackage={selectedPackage} + onPackageSelect={handlePackageSelect} + placeholder="패키지 선택..." + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재그룹 선택 */} + <FormField + control={form.control} + name="majorItemMaterialCategory" + render={({ field }) => ( + <FormItem> + <FormLabel>자재그룹 <span className="text-red-500">*</span></FormLabel> + <MaterialGroupSelector + selectedMaterials={selectedMaterials} + onMaterialsChange={handleMaterialsChange} + singleSelect={true} + placeholder="자재그룹을 검색하세요..." + noValuePlaceHolder="자재그룹을 선택해주세요" + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* SM 코드 */} + <FormField + control={form.control} + name="smCode" + render={({ field }) => ( + <FormItem> + <FormLabel>SM 코드 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="SM 코드 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 정보 표시 */} + {form.watch("projectId") && ( + <div className="grid grid-cols-2 gap-4 pt-4 border-t"> + <div> + <p className="text-sm text-muted-foreground">프로젝트 코드</p> + <p className="font-medium">{form.watch("projectCode") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">프로젝트명</p> + <p className="font-medium">{form.watch("projectName") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">발주처</p> + <p className="font-medium">{form.watch("projectCompany") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">현장</p> + <p className="font-medium">{form.watch("projectSite") || "-"}</p> + </div> + </div> + )} + </CardContent> + </Card> + + {/* 기본 정보 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="requestTitle" + render={({ field }) => ( + <FormItem> + <FormLabel>요청 제목 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="예: 프로젝트 A용 밸브 구매" + {...field} + /> + </FormControl> + <FormDescription> + 자동 생성된 제목을 수정할 수 있습니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requestDescription" + render={({ field }) => ( + <FormItem> + <FormLabel>요청 설명</FormLabel> + <FormControl> + <Textarea + placeholder="구매 요청에 대한 상세 설명을 입력하세요" + className="min-h-[100px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 예산 및 납기 */} + <div className="space-y-4"> + <h3 className="text-sm font-medium">예산 및 납기</h3> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="estimatedBudget" + render={({ field }) => ( + <FormItem> + <FormLabel>예상 예산</FormLabel> + <FormControl> + <Input placeholder="예: 10,000,000원" {...field} /> + </FormControl> + <FormDescription> + 전체 구매 예상 예산을 입력하세요 + </FormDescription> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requestedDeliveryDate" + render={({ field }) => ( + <FormItem> + <FormLabel>희망 납기일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "yyyy-MM-dd") + ) : ( + <span>날짜 선택</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date < new Date()} + /> + </PopoverContent> + </Popover> + <FormDescription> + 자재가 필요한 날짜를 선택하세요 + </FormDescription> + </FormItem> + )} + /> + </div> + </div> + </TabsContent> + + <TabsContent value="items" className="space-y-4 mt-6"> + {/* 품목 목록 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">품목 목록</h3> + <Button + type="button" + variant="outline" + size="sm" + onClick={addItem} + > + <Plus className="mr-2 h-4 w-4" /> + 품목 추가 + </Button> + </div> + + {fields.length === 0 ? ( + <Card> + <CardContent className="flex flex-col items-center justify-center py-8"> + <Package className="h-12 w-12 text-muted-foreground mb-4" /> + <p className="text-sm text-muted-foreground mb-4"> + 아직 추가된 품목이 없습니다 + </p> + <Button + type="button" + variant="outline" + size="sm" + onClick={addItem} + > + <Plus className="mr-2 h-4 w-4" /> + 첫 품목 추가 + </Button> + </CardContent> + </Card> + ) : ( + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">아이템 코드</TableHead> + <TableHead className="w-[150px]">아이템명 <span className="text-red-500">*</span></TableHead> + <TableHead>사양</TableHead> + <TableHead className="w-[80px]">수량 <span className="text-red-500">*</span></TableHead> + <TableHead className="w-[80px]">단위 <span className="text-red-500">*</span></TableHead> + <TableHead className="w-[120px]">예상 단가</TableHead> + <TableHead className="w-[120px]">예상 금액</TableHead> + <TableHead>비고</TableHead> + <TableHead className="w-[50px]"></TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => ( + <TableRow key={field.id}> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="코드" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.itemName`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="아이템명" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.specification`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="사양" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="1" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.unit`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="개" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.estimatedUnitPrice`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="0" + placeholder="0" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <div className="text-right font-medium"> + {new Intl.NumberFormat('ko-KR').format( + (form.watch(`items.${index}.quantity`) || 0) * + (form.watch(`items.${index}.estimatedUnitPrice`) || 0) + )} + </div> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.remarks`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="비고" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => remove(index)} + disabled={fields.length === 1} + > + <Trash2 className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + + <div className="flex justify-between items-center p-4 border-t bg-muted/30"> + <div className="text-sm text-muted-foreground"> + 총 {fields.length}개 품목 + </div> + <div className="text-right"> + <p className="text-sm text-muted-foreground">예상 총액</p> + <p className="text-lg font-semibold"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(calculateTotal())} + </p> + </div> + </div> + </div> + )} + </TabsContent> + + <TabsContent value="files" className="space-y-4 mt-6"> + {/* 파일 업로드 영역 */} + <Card> + <CardHeader> + <CardTitle className="text-base">첨부파일 업로드 <span className="text-red-500">*</span></CardTitle> + <CardDescription> + 설계 도면, 사양서, 견적서 등 구매 요청 관련 문서를 업로드하세요 (필수) + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 필수 알림 */} + <FormField + control={form.control} + name="attachments" + render={({ field }) => ( + <FormItem> + <FormMessage /> + </FormItem> + )} + /> + + {/* 업로드 진행 상태 */} + {isUploading && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription className="space-y-2"> + <p>파일을 업로드 중입니다...</p> + <Progress value={uploadProgress} className="w-full" /> + </AlertDescription> + </Alert> + )} + + {/* Dropzone */} + <Dropzone + onDrop={handleFileUpload} + accept={{ + 'application/pdf': ['.pdf'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + 'application/x-zip-compressed': ['.zip'], + 'application/x-rar-compressed': ['.rar'], + }} + maxSize={50 * 1024 * 1024} // 50MB + disabled={isUploading} + > + <DropzoneInput /> + <DropzoneZone> + <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" /> + <DropzoneTitle> + 파일을 드래그하거나 클릭하여 선택하세요 + </DropzoneTitle> + <DropzoneDescription> + PDF, Excel, Word, 이미지 파일 등을 업로드할 수 있습니다 (최대 50MB) + </DropzoneDescription> + </DropzoneZone> + </Dropzone> + + {/* 업로드된 파일 목록 */} + {uploadedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h4 className="text-sm font-medium">업로드된 파일</h4> + <span className="text-sm text-muted-foreground"> + 총 {uploadedFiles.length}개 파일 + </span> + </div> + + <FileList> + {uploadedFiles.map((file) => ( + <FileListItem key={file.fileName}> + <FileListIcon> + <FileIcon className="h-4 w-4" /> + </FileListIcon> + <FileListInfo> + <FileListHeader> + <FileListName> + {file.originalFileName} + </FileListName> + <FileListSize> + {file.fileSize} + </FileListSize> + </FileListHeader> + </FileListInfo> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleFileRemove(file.fileName)} + disabled={isLoading || isUploading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + + {/* 파일 업로드 안내 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <ul className="list-disc list-inside space-y-1 text-sm"> + <li className="font-medium">최소 1개 이상의 파일 첨부가 필수입니다</li> + <li>최대 파일 크기: 50MB</li> + <li>지원 형식: PDF, Excel, Word, 이미지 파일</li> + <li>여러 파일을 한 번에 선택하여 업로드할 수 있습니다</li> + </ul> + </AlertDescription> + </Alert> + </CardContent> + </Card> + </TabsContent> + </div> + </Tabs> + + <DialogFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={handleClose} + disabled={isLoading || isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading || isUploading || !form.formState.isValid} + > + {isLoading ? "등록 중..." : "구매요청 등록"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/create-rfq-dialog.tsx b/lib/itb/table/create-rfq-dialog.tsx new file mode 100644 index 00000000..57a4b9d4 --- /dev/null +++ b/lib/itb/table/create-rfq-dialog.tsx @@ -0,0 +1,380 @@ +// components/purchase-requests/create-rfq-dialog.tsx +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Badge } from "@/components/ui/badge"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { + FileText, + Package, + AlertCircle, + CheckCircle, + User, + ChevronsUpDown, + Check, + Loader2, + Info +} from "lucide-react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import type { PurchaseRequestView } from "@/db/schema"; +import { approvePurchaseRequestsAndCreateRfqs } from "../service"; +import { getPUsersForFilter } from "@/lib/rfq-last/service"; +import { useRouter } from "next/navigation"; + +interface CreateRfqDialogProps { + requests: PurchaseRequestView[]; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function CreateRfqDialog({ + requests, + open, + onOpenChange, + onSuccess, +}: CreateRfqDialogProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [userPopoverOpen, setUserPopoverOpen] = React.useState(false); + const [users, setUsers] = React.useState<any[]>([]); + const [selectedUser, setSelectedUser] = React.useState<any>(null); + const [isLoadingUsers, setIsLoadingUsers] = React.useState(false); + const [userSearchTerm, setUserSearchTerm] = React.useState(""); + const router = useRouter(); + + // 유저 목록 로드 + React.useEffect(() => { + const loadUsers = async () => { + setIsLoadingUsers(true); + try { + const userList = await getPUsersForFilter(); + setUsers(userList); + } catch (error) { + console.log("사용자 목록 로드 오류:", error); + toast.error("사용자 목록을 불러오는데 실패했습니다"); + } finally { + setIsLoadingUsers(false); + } + }; + + if (open) { + loadUsers(); + } + }, [open]); + + // 검색된 사용자 필터링 + const filteredUsers = React.useMemo(() => { + if (!userSearchTerm) return users; + + return users.filter(user => + user.name.toLowerCase().includes(userSearchTerm.toLowerCase()) || + user.userCode?.toLowerCase().includes(userSearchTerm.toLowerCase()) + ); + }, [users, userSearchTerm]); + + // 유효한 요청만 필터링 (이미 RFQ 생성된 것 제외) + const validRequests = requests.filter(r => r.status !== "RFQ생성완료"); + const invalidRequests = requests.filter(r => r.status === "RFQ생성완료"); + + const handleSelectUser = (user: any) => { + setSelectedUser(user); + setUserPopoverOpen(false); + }; + + const handleSubmit = async () => { + if (validRequests.length === 0) { + toast.error("RFQ를 생성할 수 있는 구매 요청이 없습니다"); + return; + } + + try { + setIsLoading(true); + + const requestIds = validRequests.map(r => r.id); + const results = await approvePurchaseRequestsAndCreateRfqs( + requestIds, + selectedUser?.id + ); + + const successCount = results.filter(r => r.success).length; + const skipCount = results.filter(r => r.skipped).length; + + if (successCount > 0) { + toast.success(`${successCount}개의 RFQ가 생성되었습니다`); + } + + if (skipCount > 0) { + toast.info(`${skipCount}개는 이미 RFQ가 생성되어 건너뛰었습니다`); + } + + onOpenChange(false); + onSuccess?.(); + router.refresh() + } catch (error) { + console.error("RFQ 생성 오류:", error); + toast.error("RFQ 생성 중 오류가 발생했습니다"); + } finally { + setIsLoading(false); + } + }; + + const handleClose = () => { + if (!isLoading) { + setSelectedUser(null); + setUserSearchTerm(""); + onOpenChange(false); + } + }; + + return ( + <Dialog open={open} onOpenChange={handleClose}> + <DialogContent className="max-w-4xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <FileText className="h-5 w-5" /> + RFQ 생성 + </DialogTitle> + <DialogDescription> + 선택한 구매 요청을 기반으로 RFQ를 생성합니다. + {invalidRequests.length > 0 && " 이미 RFQ가 생성된 항목은 제외됩니다."} + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {/* 경고 메시지 */} + {invalidRequests.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {invalidRequests.length}개 항목은 이미 RFQ가 생성되어 제외됩니다. + </AlertDescription> + </Alert> + )} + + {/* 구매 담당자 선택 */} + <div className="space-y-2"> + <label className="text-sm font-medium"> + 구매 담당자 (선택사항) + </label> + <Popover open={userPopoverOpen} onOpenChange={setUserPopoverOpen}> + <PopoverTrigger asChild> + <Button + type="button" + variant="outline" + className="w-full justify-between h-10" + disabled={isLoadingUsers} + > + {isLoadingUsers ? ( + <> + <span>담당자 로딩 중...</span> + <Loader2 className="ml-2 h-4 w-4 animate-spin" /> + </> + ) : ( + <> + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {selectedUser ? ( + <> + {selectedUser.name} + {selectedUser.userCode && ( + <span className="text-muted-foreground"> + ({selectedUser.userCode}) + </span> + )} + </> + ) : ( + <span className="text-muted-foreground"> + 구매 담당자를 선택하세요 (선택사항) + </span> + )} + </span> + <ChevronsUpDown className="h-4 w-4 opacity-50" /> + </> + )} + </Button> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput + placeholder="이름 또는 코드로 검색..." + value={userSearchTerm} + onValueChange={setUserSearchTerm} + /> + <CommandList className="max-h-[300px]"> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <CommandGroup> + {filteredUsers.map((user) => ( + <CommandItem + key={user.id} + onSelect={() => handleSelectUser(user)} + className="flex items-center justify-between" + > + <span className="flex items-center gap-2"> + <User className="h-4 w-4" /> + {user.name} + {user.userCode && ( + <span className="text-muted-foreground text-sm"> + ({user.userCode}) + </span> + )} + </span> + <Check + className={cn( + "h-4 w-4", + selectedUser?.id === user.id ? "opacity-100" : "opacity-0" + )} + /> + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <p className="text-xs text-muted-foreground"> + 구매 담당자를 선택하지 않으면 나중에 지정할 수 있습니다 + </p> + </div> + + {/* RFQ 생성 대상 목록 */} + <div className="space-y-2"> + <label className="text-sm font-medium"> + RFQ 생성 대상 ({validRequests.length}개) + </label> + <div className="border rounded-lg max-h-[300px] overflow-y-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[140px]">요청번호</TableHead> + <TableHead>요청제목</TableHead> + <TableHead className="w-[120px]">프로젝트</TableHead> + <TableHead className="w-[100px]">패키지</TableHead> + <TableHead className="w-[80px] text-center">품목</TableHead> + <TableHead className="w-[80px] text-center">첨부</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {validRequests.length === 0 ? ( + <TableRow> + <TableCell colSpan={6} className="text-center text-muted-foreground py-8"> + RFQ를 생성할 수 있는 구매 요청이 없습니다 + </TableCell> + </TableRow> + ) : ( + validRequests.map((request) => ( + <TableRow key={request.id}> + <TableCell className="font-mono text-sm"> + {request.requestCode} + </TableCell> + <TableCell className="max-w-[250px]"> + <div className="truncate" title={request.requestTitle}> + {request.requestTitle} + </div> + </TableCell> + <TableCell> + <div className="truncate" title={request.projectName}> + {request.projectCode} + </div> + </TableCell> + <TableCell> + <div className="truncate" title={request.packageName}> + {request.packageNo} + </div> + </TableCell> + <TableCell className="text-center"> + {request.itemCount > 0 && ( + <Badge variant="secondary" className="gap-1"> + <Package className="h-3 w-3" /> + {request.itemCount} + </Badge> + )} + </TableCell> + <TableCell className="text-center"> + {request.attachmentCount > 0 && ( + <Badge variant="secondary" className="gap-1"> + <FileText className="h-3 w-3" /> + {request.attachmentCount} + </Badge> + )} + </TableCell> + </TableRow> + )) + )} + </TableBody> + </Table> + </div> + </div> + + {/* 안내 메시지 */} + <Alert> + <Info className="h-4 w-4" /> + <AlertDescription> + <ul className="list-disc list-inside space-y-1 text-sm"> + <li>RFQ 생성 시 구매 요청의 첨부파일이 자동으로 이관됩니다</li> + <li>구매 요청 상태가 "RFQ생성완료"로 변경됩니다</li> + <li>각 구매 요청별로 개별 RFQ가 생성됩니다</li> + </ul> + </AlertDescription> + </Alert> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={handleClose} + disabled={isLoading} + > + 취소 + </Button> + <Button + onClick={handleSubmit} + disabled={isLoading || validRequests.length === 0} + > + {isLoading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + RFQ 생성 중... + </> + ) : ( + <> + <CheckCircle className="mr-2 h-4 w-4" /> + RFQ 생성 ({validRequests.length}개) + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/delete-purchase-request-dialog.tsx b/lib/itb/table/delete-purchase-request-dialog.tsx new file mode 100644 index 00000000..2f09cf70 --- /dev/null +++ b/lib/itb/table/delete-purchase-request-dialog.tsx @@ -0,0 +1,225 @@ +// components/purchase-requests/delete-purchase-request-dialog.tsx +"use client" + +import * as React from "react" +import { type PurchaseRequestView } from "@/db/schema/purchase-requests-view" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" + +import { deletePurchaseRequests } from "../service" + +interface DeletePurchaseRequestDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + requests?: Row<PurchaseRequestView>["original"][] | PurchaseRequestView[] + request?: PurchaseRequestView // 단일 삭제용 + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeletePurchaseRequestDialog({ + requests: requestsProp, + request, + showTrigger = true, + onSuccess, + ...props +}: DeletePurchaseRequestDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + // 단일 또는 복수 요청 처리 + const requests = requestsProp || (request ? [request] : []) + const isMultiple = requests.length > 1 + + // 삭제 불가능한 상태 체크 + const nonDeletableRequests = requests.filter( + req => req.status !== "작성중" + ) + const canDelete = nonDeletableRequests.length === 0 + + function onDelete() { + if (!canDelete) { + toast.error("작성중 상태의 요청만 삭제할 수 있습니다.") + return + } + + startDeleteTransition(async () => { + const { error } = await deletePurchaseRequests({ + ids: requests.map((req) => req.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success( + isMultiple + ? `${requests.length}개의 구매요청이 삭제되었습니다.` + : "구매요청이 삭제되었습니다." + ) + onSuccess?.() + }) + } + + const dialogContent = ( + <> + <div className="space-y-4"> + <div> + <p className="text-sm text-muted-foreground"> + 이 작업은 되돌릴 수 없습니다. + {isMultiple ? ( + <> + 선택한 <span className="font-medium text-foreground">{requests.length}개</span>의 구매요청이 영구적으로 삭제됩니다. + </> + ) : ( + <> + 구매요청 <span className="font-medium text-foreground">{requests[0]?.requestCode}</span>이(가) 영구적으로 삭제됩니다. + </> + )} + </p> + </div> + + {/* 삭제할 요청 목록 표시 (복수인 경우) */} + {isMultiple && ( + <div className="rounded-lg border bg-muted/30 p-3"> + <p className="text-sm font-medium mb-2">삭제할 구매요청:</p> + <ul className="space-y-1 max-h-32 overflow-y-auto"> + {requests.map((req) => ( + <li key={req.id} className="text-sm text-muted-foreground"> + <span className="font-mono">{req.requestCode}</span> + {" - "} + <span className="truncate">{req.requestTitle}</span> + </li> + ))} + </ul> + </div> + )} + + {/* 삭제 불가능한 요청이 있는 경우 경고 */} + {nonDeletableRequests.length > 0 && ( + <div className="rounded-lg border border-destructive/50 bg-destructive/10 p-3"> + <p className="text-sm font-medium text-destructive mb-2"> + 삭제할 수 없는 요청: + </p> + <ul className="space-y-1"> + {nonDeletableRequests.map((req) => ( + <li key={req.id} className="text-sm text-destructive/80"> + <span className="font-mono">{req.requestCode}</span> + {" - 상태: "} + <span className="font-medium">{req.status}</span> + </li> + ))} + </ul> + <p className="text-xs text-destructive/60 mt-2"> + ※ 작성중 상태의 요청만 삭제할 수 있습니다. + </p> + </div> + )} + </div> + </> + ) + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" disabled={requests.length === 0}> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 {requests.length > 0 && `(${requests.length})`} + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>구매요청을 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + {dialogContent} + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Delete selected requests" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !canDelete} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm" disabled={requests.length === 0}> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 {requests.length > 0 && `(${requests.length})`} + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>구매요청을 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + {dialogContent} + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="Delete selected requests" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending || !canDelete} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/itb/table/edit-purchase-request-sheet.tsx b/lib/itb/table/edit-purchase-request-sheet.tsx new file mode 100644 index 00000000..8a818ca5 --- /dev/null +++ b/lib/itb/table/edit-purchase-request-sheet.tsx @@ -0,0 +1,1081 @@ +// components/purchase-requests/edit-purchase-request-sheet.tsx +"use client"; + +import * as React from "react"; +import { useForm, useFieldArray } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import * as z from "zod"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetFooter, +} from "@/components/ui/sheet"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ProjectSelector } from "@/components/ProjectSelector"; +import { PackageSelector } from "@/components/PackageSelector"; +import type { PackageItem } from "@/lib/items/service"; +import { MaterialGroupSelector } from "@/components/common/material/material-group-selector"; +import { CalendarIcon, X, Plus, FileText, Package, Trash2, Upload, FileIcon, AlertCircle, Paperclip, Save } from "lucide-react"; +import { format } from "date-fns"; +import { cn } from "@/lib/utils"; +import { updatePurchaseRequest } from "../service"; +import { toast } from "sonner"; +import { nanoid } from "nanoid"; +import type { PurchaseRequestView } from "@/db/schema"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { Progress } from "@/components/ui/progress"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +const attachmentSchema = z.object({ + fileName: z.string(), + originalFileName: z.string(), + filePath: z.string(), + fileSize: z.number(), + fileType: z.string(), +}); + +const itemSchema = z.object({ + id: z.string(), + itemCode: z.string(), + itemName: z.string().min(1, "아이템명을 입력해주세요"), + specification: z.string().default(""), + quantity: z.coerce.number().min(1, "수량은 1 이상이어야 합니다"), + unit: z.string().min(1, "단위를 입력해주세요"), + estimatedUnitPrice: z.coerce.number().optional(), + remarks: z.string().optional(), +}); + +const formSchema = z.object({ + requestTitle: z.string().min(1, "요청 제목을 입력해주세요"), + requestDescription: z.string().optional(), + projectId: z.number({ + required_error: "프로젝트를 선택해주세요", + }), + projectCode: z.string().min(1), + projectName: z.string().min(1), + projectCompany: z.string().optional(), + projectSite: z.string().optional(), + classNo: z.string().optional(), + packageNo: z.string({ + required_error: "패키지를 선택해주세요", + }).min(1, "패키지를 선택해주세요"), + packageName: z.string().min(1), + majorItemMaterialCategory: z.string({ + required_error: "자재그룹을 선택해주세요", + }).min(1, "자재그룹을 선택해주세요"), + majorItemMaterialDescription: z.string().min(1), + smCode: z.string({ + required_error: "SM 코드가 필요합니다", + }).min(1, "SM 코드가 필요합니다"), + estimatedBudget: z.string().optional(), + requestedDeliveryDate: z.date().optional(), + items: z.array(itemSchema).optional().default([]), +}); + +type FormData = z.infer<typeof formSchema>; + +interface EditPurchaseRequestSheetProps { + request: PurchaseRequestView; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +export function EditPurchaseRequestSheet({ + request, + open, + onOpenChange, + onSuccess, +}: EditPurchaseRequestSheetProps) { + const [isLoading, setIsLoading] = React.useState(false); + const [activeTab, setActiveTab] = React.useState("basic"); + const [selectedPackage, setSelectedPackage] = React.useState<PackageItem | null>(null); + const [selectedMaterials, setSelectedMaterials] = React.useState<any[]>([]); + const [resetKey, setResetKey] = React.useState(0); + const [newFiles, setNewFiles] = React.useState<File[]>([]); // 새로 추가할 파일 + const [existingFiles, setExistingFiles] = React.useState<z.infer<typeof attachmentSchema>[]>([]); // 기존 파일 + const [isUploading, setIsUploading] = React.useState(false); + const [uploadProgress, setUploadProgress] = React.useState(0); + + // 기존 아이템 처리 + const existingItems = React.useMemo(() => { + if (!request.items || !Array.isArray(request.items) || request.items.length === 0) { + return []; + } + return request.items.map(item => ({ + ...item, + id: item.id || nanoid(), + })); + }, [request.items]); + + // 기존 첨부파일 로드 (실제로는 API에서 가져와야 함) + React.useEffect(() => { + // TODO: 기존 첨부파일 로드 API 호출 + // setExistingFiles(request.attachments || []); + }, [request.id]); + + // 기존 자재그룹 데이터 설정 + React.useEffect(() => { + if (request.majorItemMaterialCategory && request.majorItemMaterialDescription) { + setSelectedMaterials([{ + materialGroupCode: request.majorItemMaterialCategory, + materialGroupDescription: request.majorItemMaterialDescription, + displayText:`${request.majorItemMaterialCategory} - ${request.majorItemMaterialDescription}` + }]); + } + }, [request.majorItemMaterialCategory, request.majorItemMaterialDescription]); + + // 기존 패키지 데이터 설정 + React.useEffect(() => { + if (request.packageNo && request.packageName) { + setSelectedPackage({ + packageCode: request.packageNo, + description: request.packageName, + smCode: request.smCode || "", + } as PackageItem); + } + }, [request.packageNo, request.packageName, request.smCode]); + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + mode: "onChange", + criteriaMode: "all", + defaultValues: { + requestTitle: request.requestTitle || "", + requestDescription: request.requestDescription || "", + projectId: request.projectId || undefined, + projectCode: request.projectCode || "", + projectName: request.projectName || "", + projectCompany: request.projectCompany || "", + projectSite: request.projectSite || "", + classNo: request.classNo || "", + packageNo: request.packageNo || "", + packageName: request.packageName || "", + majorItemMaterialCategory: request.majorItemMaterialCategory || "", + majorItemMaterialDescription: request.majorItemMaterialDescription || "", + smCode: request.smCode || "", + estimatedBudget: request.estimatedBudget || "", + requestedDeliveryDate: request.requestedDeliveryDate + ? new Date(request.requestedDeliveryDate) + : undefined, + items: existingItems, + }, + }); + + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: "items", + }); + + // 프로젝트 선택 처리 + const handleProjectSelect = (project: any) => { + form.setValue("projectId", project.id); + form.setValue("projectCode", project.projectCode); + form.setValue("projectName", project.projectName); + form.setValue("projectCompany", project.projectCompany); + form.setValue("projectSite", project.projectSite); + + // 프로젝트 변경시 패키지 초기화 + setSelectedPackage(null); + form.setValue("packageNo", ""); + form.setValue("packageName", ""); + form.setValue("smCode", ""); + + // requestTitle 업데이트 + updateRequestTitle(project.projectName, "", ""); + }; + + // 패키지 선택 처리 + const handlePackageSelect = (packageItem: PackageItem) => { + setSelectedPackage(packageItem); + form.setValue("packageNo", packageItem.packageCode); + form.setValue("packageName", packageItem.description); + + // SM 코드가 패키지에 있으면 자동 설정 + if (packageItem.smCode) { + form.setValue("smCode", packageItem.smCode); + } + + // requestTitle 업데이트 + const projectName = form.getValues("projectName"); + const materialDesc = form.getValues("majorItemMaterialDescription"); + updateRequestTitle(projectName, packageItem.description, materialDesc); + }; + + // 자재그룹 선택 처리 + const handleMaterialsChange = (materials: any[]) => { + setSelectedMaterials(materials); + if (materials.length > 0) { + const material = materials[0]; // single select이므로 첫번째 항목만 + form.setValue("majorItemMaterialCategory", material.materialGroupCode); + form.setValue("majorItemMaterialDescription", material.materialGroupDescription); + + // requestTitle 업데이트 + const projectName = form.getValues("projectName"); + const packageName = form.getValues("packageName"); + updateRequestTitle(projectName, packageName, material.materialGroupDescription); + } else { + form.setValue("majorItemMaterialCategory", ""); + form.setValue("majorItemMaterialDescription", ""); + } + }; + + // requestTitle 자동 생성 + const updateRequestTitle = (projectName: string, packageName: string, materialDesc: string) => { + const parts = []; + if (projectName) parts.push(projectName); + if (packageName) parts.push(packageName); + if (materialDesc) parts.push(materialDesc); + + if (parts.length > 0) { + const title = `${parts.join(" - ")} 구매 요청`; + form.setValue("requestTitle", title); + } + }; + + const addItem = () => { + append({ + id: nanoid(), + itemCode: "", + itemName: "", + specification: "", + quantity: 1, + unit: "개", + estimatedUnitPrice: undefined, + remarks: "", + }); + }; + + const calculateTotal = () => { + const items = form.watch("items"); + return items?.reduce((sum, item) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return sum + subtotal; + }, 0) || 0; + }; + + // 파일 추가 핸들러 (로컬 상태로만 저장) + const handleFileAdd = (files: File[]) => { + if (files.length === 0) return; + + // 중복 파일 체크 + const duplicates = files.filter(file => + newFiles.some(existing => existing.name === file.name) + ); + + if (duplicates.length > 0) { + toast.warning(`${duplicates.length}개 파일이 이미 추가되어 있습니다`); + } + + const uniqueFiles = files.filter(file => + !newFiles.some(existing => existing.name === file.name) + ); + + if (uniqueFiles.length > 0) { + setNewFiles(prev => [...prev, ...uniqueFiles]); + toast.success(`${uniqueFiles.length}개 파일이 추가되었습니다`); + } + }; + + // 새 파일 삭제 핸들러 + const handleNewFileRemove = (fileName: string) => { + setNewFiles(prev => prev.filter(f => f.name !== fileName)); + }; + + // 기존 파일 삭제 핸들러 (실제로는 서버에 삭제 요청) + const handleExistingFileRemove = (fileName: string) => { + // TODO: 서버에 삭제 표시 + setExistingFiles(prev => prev.filter(f => f.fileName !== fileName)); + }; + + const onSubmit = async (values: FormData) => { + try { + setIsLoading(true); + + // 새 파일이 있으면 먼저 업로드 + let uploadedAttachments: z.infer<typeof attachmentSchema>[] = []; + + if (newFiles.length > 0) { + setIsUploading(true); + setUploadProgress(10); + + const formData = new FormData(); + newFiles.forEach(file => { + formData.append("files", file); + }); + + setUploadProgress(30); + + try { + const response = await fetch("/api/upload/purchase-request", { + method: "POST", + body: formData, + }); + + setUploadProgress(60); + + if (!response.ok) { + throw new Error("파일 업로드 실패"); + } + + const data = await response.json(); + uploadedAttachments = data.files; + setUploadProgress(80); + } catch (error) { + toast.error("파일 업로드 중 오류가 발생했습니다"); + setIsUploading(false); + setIsLoading(false); + setUploadProgress(0); + return; + } + } + + setUploadProgress(90); + + // 구매 요청 업데이트 (기존 파일 + 새로 업로드된 파일) + const result = await updatePurchaseRequest(request.id, { + ...values, + items: values.items, + attachments: [...existingFiles, ...uploadedAttachments], + }); + + setUploadProgress(100); + + if (result.error) { + toast.error(result.error); + return; + } + + toast.success("구매 요청이 수정되었습니다"); + handleClose(); + onSuccess?.(); + } catch (error) { + toast.error("구매 요청 수정에 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + setIsUploading(false); + setUploadProgress(0); + } + }; + + const handleClose = () => { + form.reset(); + setSelectedPackage(null); + setSelectedMaterials([]); + setNewFiles([]); + setExistingFiles([]); + setActiveTab("basic"); + setResetKey((k) => k + 1); + onOpenChange(false); + }; + + const canEdit = request.status === "작성중"; + const totalFileCount = existingFiles.length + newFiles.length; + + return ( + <Sheet open={open} onOpenChange={handleClose}> + <SheetContent key={resetKey} className="w-[900px] max-w-[900px] overflow-hidden flex flex-col min-h-0" style={{width:900, maxWidth:900}}> + <SheetHeader> + <SheetTitle>구매 요청 수정</SheetTitle> + <SheetDescription> + 요청번호: {request.requestCode} | 상태: {request.status} + </SheetDescription> + </SheetHeader> + + {!canEdit ? ( + <div className="flex-1 flex items-center justify-center"> + <Card> + <CardContent className="pt-6"> + <p className="text-center text-muted-foreground"> + 작성중 상태의 요청만 수정할 수 있습니다. + </p> + </CardContent> + </Card> + </div> + ) : ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 overflow-hidden flex flex-col min-h-0"> + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0"> + <TabsList className="grid w-full grid-cols-3 flex-shrink-0"> + <TabsTrigger value="basic"> + <FileText className="mr-2 h-4 w-4" /> + 기본 정보 + </TabsTrigger> + <TabsTrigger value="items"> + <Package className="mr-2 h-4 w-4" /> + 품목 정보 + {fields.length > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {fields.length} + </span> + )} + </TabsTrigger> + <TabsTrigger value="files"> + <Paperclip className="mr-2 h-4 w-4" /> + 첨부파일 + {totalFileCount > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {totalFileCount} + </span> + )} + </TabsTrigger> + </TabsList> + + <div className="flex-1 overflow-y-auto px-1 min-h-0"> + <TabsContent value="basic" className="space-y-6 mt-6"> + {/* 프로젝트 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">프로젝트 정보 <span className="text-red-500">*</span></CardTitle> + <CardDescription>프로젝트, 패키지, 자재그룹을 순서대로 선택하세요</CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 프로젝트 선택 */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 <span className="text-red-500">*</span></FormLabel> + <ProjectSelector + key={`project-${resetKey}`} + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트를 검색하여 선택..." + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지 선택 */} + <FormField + control={form.control} + name="packageNo" + render={({ field }) => ( + <FormItem> + <FormLabel>패키지 <span className="text-red-500">*</span></FormLabel> + <PackageSelector + projectNo={form.watch("projectCode")} + selectedPackage={selectedPackage} + onPackageSelect={handlePackageSelect} + placeholder="패키지 선택..." + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재그룹 선택 */} + <FormField + control={form.control} + name="majorItemMaterialCategory" + render={({ field }) => ( + <FormItem> + <FormLabel>자재그룹 <span className="text-red-500">*</span></FormLabel> + <MaterialGroupSelector + selectedMaterials={selectedMaterials} + onMaterialsChange={handleMaterialsChange} + singleSelect={true} + placeholder="자재그룹을 검색하세요..." + noValuePlaceHolder="자재그룹을 선택해주세요" + /> + <FormMessage /> + </FormItem> + )} + /> + + {/* SM 코드 */} + <FormField + control={form.control} + name="smCode" + render={({ field }) => ( + <FormItem> + <FormLabel>SM 코드 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="SM 코드 입력" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 정보 표시 */} + {form.watch("projectId") && ( + <div className="grid grid-cols-2 gap-4 pt-4 border-t"> + <div> + <p className="text-sm text-muted-foreground">프로젝트 코드</p> + <p className="font-medium">{form.watch("projectCode") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">프로젝트명</p> + <p className="font-medium">{form.watch("projectName") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">발주처</p> + <p className="font-medium">{form.watch("projectCompany") || "-"}</p> + </div> + <div> + <p className="text-sm text-muted-foreground">현장</p> + <p className="font-medium">{form.watch("projectSite") || "-"}</p> + </div> + </div> + )} + </CardContent> + </Card> + + {/* 기본 정보 */} + <div className="space-y-4"> + <FormField + control={form.control} + name="requestTitle" + render={({ field }) => ( + <FormItem> + <FormLabel>요청 제목 <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Input + placeholder="예: 프로젝트 A용 밸브 구매" + {...field} + /> + </FormControl> + <FormDescription> + 자동 생성된 제목을 수정할 수 있습니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requestDescription" + render={({ field }) => ( + <FormItem> + <FormLabel>요청 설명</FormLabel> + <FormControl> + <Textarea + placeholder="구매 요청에 대한 상세 설명을 입력하세요" + className="min-h-[100px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 예산 및 납기 */} + <div className="space-y-4"> + <h3 className="text-sm font-medium">예산 및 납기</h3> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="estimatedBudget" + render={({ field }) => ( + <FormItem> + <FormLabel>예상 예산</FormLabel> + <FormControl> + <Input placeholder="예: 10,000,000원" {...field} /> + </FormControl> + <FormDescription> + 전체 구매 예상 예산을 입력하세요 + </FormDescription> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="requestedDeliveryDate" + render={({ field }) => ( + <FormItem> + <FormLabel>희망 납기일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "yyyy-MM-dd") + ) : ( + <span>날짜 선택</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date < new Date()} + /> + </PopoverContent> + </Popover> + <FormDescription> + 자재가 필요한 날짜를 선택하세요 + </FormDescription> + </FormItem> + )} + /> + </div> + </div> + </TabsContent> + + <TabsContent value="items" className="space-y-4 mt-6"> + {/* 품목 목록 */} + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">품목 목록</h3> + <Button + type="button" + variant="outline" + size="sm" + onClick={addItem} + > + <Plus className="mr-2 h-4 w-4" /> + 품목 추가 + </Button> + </div> + + {fields.length === 0 ? ( + <Card> + <CardContent className="flex flex-col items-center justify-center py-8"> + <Package className="h-12 w-12 text-muted-foreground mb-4" /> + <p className="text-sm text-muted-foreground mb-4"> + 아직 추가된 품목이 없습니다 + </p> + <Button + type="button" + variant="outline" + size="sm" + onClick={addItem} + > + <Plus className="mr-2 h-4 w-4" /> + 첫 품목 추가 + </Button> + </CardContent> + </Card> + ) : ( + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[120px]">아이템 코드</TableHead> + <TableHead className="w-[150px]">아이템명 <span className="text-red-500">*</span></TableHead> + <TableHead>사양</TableHead> + <TableHead className="w-[80px]">수량 <span className="text-red-500">*</span></TableHead> + <TableHead className="w-[80px]">단위 <span className="text-red-500">*</span></TableHead> + <TableHead className="w-[120px]">예상 단가</TableHead> + <TableHead className="w-[120px]">예상 금액</TableHead> + <TableHead>비고</TableHead> + <TableHead className="w-[50px]"></TableHead> + </TableRow> + </TableHeader> + <TableBody> + {fields.map((field, index) => ( + <TableRow key={field.id}> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.itemCode`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="코드" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.itemName`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="아이템명" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.specification`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="사양" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.quantity`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="1" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.unit`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="개" + {...field} + className="h-8" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.estimatedUnitPrice`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + type="number" + min="0" + placeholder="0" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <div className="text-right font-medium"> + {new Intl.NumberFormat('ko-KR').format( + (form.watch(`items.${index}.quantity`) || 0) * + (form.watch(`items.${index}.estimatedUnitPrice`) || 0) + )} + </div> + </TableCell> + <TableCell> + <FormField + control={form.control} + name={`items.${index}.remarks`} + render={({ field }) => ( + <FormItem> + <FormControl> + <Input + placeholder="비고" + {...field} + className="h-8" + /> + </FormControl> + </FormItem> + )} + /> + </TableCell> + <TableCell> + <Button + type="button" + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => remove(index)} + disabled={fields.length === 1} + > + <Trash2 className="h-4 w-4" /> + </Button> + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + + <div className="flex justify-between items-center p-4 border-t bg-muted/30"> + <div className="text-sm text-muted-foreground"> + 총 {fields.length}개 품목 + </div> + <div className="text-right"> + <p className="text-sm text-muted-foreground">예상 총액</p> + <p className="text-lg font-semibold"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(calculateTotal())} + </p> + </div> + </div> + </div> + )} + </TabsContent> + + <TabsContent value="files" className="space-y-4 mt-6"> + {/* 파일 업로드 영역 */} + <Card> + <CardHeader> + <CardTitle className="text-base">첨부파일 관리</CardTitle> + <CardDescription> + 설계 도면, 사양서, 견적서 등 구매 요청 관련 문서를 관리하세요 + </CardDescription> + </CardHeader> + <CardContent className="space-y-4"> + {/* 업로드 진행 상태 */} + {isUploading && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription className="space-y-2"> + <p>파일을 업로드하고 데이터를 저장 중입니다...</p> + <Progress value={uploadProgress} className="w-full" /> + </AlertDescription> + </Alert> + )} + + {/* 기존 파일 목록 */} + {existingFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium">기존 첨부파일</h4> + <FileList> + {existingFiles.map((file) => ( + <FileListItem key={file.fileName}> + <FileListIcon> + <FileIcon className="h-4 w-4" /> + </FileListIcon> + <FileListInfo> + <FileListHeader> + <FileListName> + {file.originalFileName} + </FileListName> + <FileListSize> + {file.fileSize} + </FileListSize> + </FileListHeader> + + </FileListInfo> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleExistingFileRemove(file.fileName)} + disabled={isLoading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + + {/* Dropzone */} + <Dropzone + onDrop={handleFileAdd} + accept={{ + 'application/pdf': ['.pdf'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + 'application/x-zip-compressed': ['.zip'], + 'application/x-rar-compressed': ['.rar'], + }} + maxSize={50 * 1024 * 1024} // 50MB + disabled={isLoading} + > + <DropzoneInput /> + <DropzoneZone> + <DropzoneUploadIcon className="h-12 w-12 text-muted-foreground mb-4" /> + <DropzoneTitle> + 파일을 드래그하거나 클릭하여 선택하세요 + </DropzoneTitle> + <DropzoneDescription> + PDF, Excel, Word, 이미지 파일 등을 업로드할 수 있습니다 (최대 50MB) + </DropzoneDescription> + </DropzoneZone> + </Dropzone> + + {/* 새로 추가할 파일 목록 */} + {newFiles.length > 0 && ( + <div className="space-y-2"> + <h4 className="text-sm font-medium text-blue-600">새로 추가할 파일</h4> + <FileList> + {newFiles.map((file) => ( + <FileListItem key={file.name} className="bg-blue-50"> + <FileListIcon> + <FileIcon className="h-4 w-4 text-blue-600" /> + </FileListIcon> + <FileListInfo> + <FileListHeader> + <FileListName> + {file.name} + </FileListName> + <FileListSize> + {file.size} + </FileListSize> + </FileListHeader> + <FileListDescription> + {file.type || "application/octet-stream"} + </FileListDescription> + </FileListInfo> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleNewFileRemove(file.name)} + disabled={isLoading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + + {/* 파일 업로드 안내 */} + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <ul className="list-disc list-inside space-y-1 text-sm"> + <li>새 파일은 수정 완료 시 서버로 업로드됩니다</li> + <li>최대 파일 크기: 50MB</li> + <li>지원 형식: PDF, Excel, Word, 이미지 파일</li> + <li>여러 파일을 한 번에 선택하여 추가할 수 있습니다</li> + </ul> + </AlertDescription> + </Alert> + </CardContent> + </Card> + </TabsContent> + </div> + </Tabs> + + <SheetFooter className="mt-6"> + <Button + type="button" + variant="outline" + onClick={handleClose} + disabled={isLoading || isUploading} + > + 취소 + </Button> + <Button type="submit" disabled={isLoading || isUploading || !form.formState.isValid}> + <Save className="mr-2 h-4 w-4" /> + {isLoading ? "수정 중..." : "수정 완료"} + </Button> + </SheetFooter> + </form> + </Form> + )} + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/items-dialog.tsx b/lib/itb/table/items-dialog.tsx new file mode 100644 index 00000000..dd688ce9 --- /dev/null +++ b/lib/itb/table/items-dialog.tsx @@ -0,0 +1,167 @@ +// components/purchase-requests/items-dialog.tsx +"use client"; + +import * as React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableFooter, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { Package } from "lucide-react"; + +interface Item { + id: string; + itemCode: string; + itemName: string; + specification: string; + quantity: number; + unit: string; + estimatedUnitPrice?: number; + remarks?: string; +} + +interface ItemsDialogProps { + requestId: number; + requestCode: string; + items: Item[] | any; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ItemsDialog({ + requestId, + requestCode, + items, + open, + onOpenChange, +}: ItemsDialogProps) { + // items가 없거나 배열이 아닌 경우 처리 + const itemList = React.useMemo(() => { + if (!items || !Array.isArray(items)) return []; + return items; + }, [items]); + + // 총액 계산 + const totalAmount = React.useMemo(() => { + return itemList.reduce((sum, item) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return sum + subtotal; + }, 0); + }, [itemList]); + + // 총 수량 계산 + const totalQuantity = React.useMemo(() => { + return itemList.reduce((sum, item) => sum + (item.quantity || 0), 0); + }, [itemList]); + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-5xl max-h-[80vh] overflow-hidden flex flex-col"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Package className="h-5 w-5" /> + 품목 상세 정보 + </DialogTitle> + <DialogDescription> + 요청번호: {requestCode} | 총 {itemList.length}개 품목 + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-auto"> + {itemList.length === 0 ? ( + <div className="flex items-center justify-center h-32"> + <p className="text-muted-foreground">등록된 품목이 없습니다.</p> + </div> + ) : ( + <Table> + <TableHeader className="sticky top-0 bg-background"> + <TableRow> + <TableHead className="w-[50px]">번호</TableHead> + <TableHead className="w-[120px]">아이템 코드</TableHead> + <TableHead>아이템명</TableHead> + <TableHead>사양</TableHead> + <TableHead className="text-right w-[80px]">수량</TableHead> + <TableHead className="w-[60px]">단위</TableHead> + <TableHead className="text-right w-[120px]">예상 단가</TableHead> + <TableHead className="text-right w-[140px]">예상 금액</TableHead> + <TableHead className="w-[150px]">비고</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {itemList.map((item, index) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return ( + <TableRow key={item.id || index}> + <TableCell className="text-center text-muted-foreground"> + {index + 1} + </TableCell> + <TableCell className="font-mono text-sm"> + {item.itemCode} + </TableCell> + <TableCell className="font-medium"> + {item.itemName} + </TableCell> + <TableCell className="text-sm"> + {item.specification || "-"} + </TableCell> + <TableCell className="text-right font-medium"> + {item.quantity?.toLocaleString('ko-KR')} + </TableCell> + <TableCell className="text-center"> + {item.unit} + </TableCell> + <TableCell className="text-right"> + {item.estimatedUnitPrice + ? new Intl.NumberFormat('ko-KR').format(item.estimatedUnitPrice) + : "-"} + </TableCell> + <TableCell className="text-right font-medium"> + {subtotal > 0 + ? new Intl.NumberFormat('ko-KR').format(subtotal) + : "-"} + </TableCell> + <TableCell className="text-sm text-muted-foreground"> + {item.remarks || "-"} + </TableCell> + </TableRow> + ); + })} + </TableBody> + <TableFooter> + <TableRow className="bg-muted/50"> + <TableCell colSpan={4} className="font-medium"> + 합계 + </TableCell> + <TableCell className="text-right font-bold"> + {totalQuantity.toLocaleString('ko-KR')} + </TableCell> + <TableCell></TableCell> + <TableCell></TableCell> + <TableCell className="text-right font-bold text-lg"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(totalAmount)} + </TableCell> + <TableCell></TableCell> + </TableRow> + </TableFooter> + </Table> + )} + </div> + </DialogContent> + </Dialog> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/purchase-request-columns.tsx b/lib/itb/table/purchase-request-columns.tsx new file mode 100644 index 00000000..55321a21 --- /dev/null +++ b/lib/itb/table/purchase-request-columns.tsx @@ -0,0 +1,380 @@ +// components/purchase-requests/purchase-request-columns.tsx +"use client"; + +import { type ColumnDef } from "@tanstack/react-table"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Eye, + Edit, + Trash2, + CheckCircle, + XCircle, + FileText, + Package, + Send, + Clock +} from "lucide-react"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { MoreHorizontal } from "lucide-react"; +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import type { DataTableRowAction } from "@/types/table"; +import type { PurchaseRequestView } from "@/db/schema"; + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PurchaseRequestView> | null>>; +} + +const statusConfig = { + "작성중": { + label: "작성중", + variant: "secondary" as const, + icon: Edit, + color: "text-gray-500" + }, + "요청완료": { + label: "요청완료", + variant: "default" as const, + icon: Send, + color: "text-blue-500" + }, + "검토중": { + label: "검토중", + variant: "warning" as const, + icon: Clock, + color: "text-yellow-500" + }, + "승인": { + label: "승인", + variant: "success" as const, + icon: CheckCircle, + color: "text-green-500" + }, + "반려": { + label: "반려", + variant: "destructive" as const, + icon: XCircle, + color: "text-red-500" + }, + "RFQ생성완료": { + label: "RFQ생성완료", + variant: "outline" as const, + icon: FileText, + color: "text-purple-500" + }, +}; + +export function getPurchaseRequestColumns({ + setRowAction +}: GetColumnsProps): ColumnDef<PurchaseRequestView>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + enableSorting: false, + enableHiding: false, + size: 40, + }, + { + accessorKey: "requestCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="요청번호" /> + ), + cell: ({ row }) => ( + <Button + variant="ghost" + className="h-auto p-0 font-mono font-medium text-primary hover:underline" + onClick={() => setRowAction({ row, type: "view" })} + > + {row.getValue("requestCode")} + </Button> + ), + size: 130, + }, + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const status = row.getValue("status") as keyof typeof statusConfig; + const config = statusConfig[status]; + const Icon = config?.icon; + + return ( + <Badge variant={config?.variant} className="font-medium"> + {Icon && <Icon className={`mr-1 h-3 w-3 ${config.color}`} />} + {config?.label || status} + </Badge> + ); + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + size: 120, + }, + { + accessorKey: "requestTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="요청제목" /> + ), + cell: ({ row }) => ( + <div className="max-w-[300px]"> + <div className="truncate font-medium" title={row.getValue("requestTitle")}> + {row.getValue("requestTitle")} + </div> + {row.original.requestDescription && ( + <div className="truncate text-xs text-muted-foreground mt-0.5" + title={row.original.requestDescription}> + {row.original.requestDescription} + </div> + )} + </div> + ), + size: 300, + }, + { + accessorKey: "projectName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col space-y-0.5"> + {row.original.projectCode && ( + <span className="font-mono text-xs text-muted-foreground"> + {row.original.projectCode} + </span> + )} + <span className="truncate text-sm" title={row.original.projectName}> + {row.original.projectName || "-"} + </span> + </div> + ), + size: 180, + }, + { + accessorKey: "packageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="패키지" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col space-y-0.5"> + {row.original.packageNo && ( + <span className="font-mono text-xs text-muted-foreground"> + {row.original.packageNo} + </span> + )} + <span className="truncate text-sm" title={row.original.packageName}> + {row.original.packageName || "-"} + </span> + </div> + ), + size: 150, + }, + { + id: "attachments", + header: "첨부", + cell: ({ row }) => { + const count = Number(row.original.attachmentCount) || 0; + return count > 0 ? ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2" + onClick={() => setRowAction({ row, type: "attachments" })} + > + <FileText className="h-4 w-4 mr-1" /> + {count} + </Button> + ) : ( + <span className="text-muted-foreground text-sm">-</span> + ); + }, + size: 70, + }, + { + id: "items", + header: "품목", + cell: ({ row }) => { + const count = row.original.itemCount || 0; + const totalQuantity = row.original.totalQuantity; + return count > 0 ? ( + <Button + variant="ghost" + size="sm" + className="h-8 px-2" + onClick={() => setRowAction({ row, type: "items" })} + > + <Package className="h-4 w-4 mr-1" /> + <div className="flex flex-col items-start"> + <span>{count}종</span> + {totalQuantity && ( + <span className="text-xs text-muted-foreground"> + 총 {totalQuantity}개 + </span> + )} + </div> + </Button> + ) : ( + <span className="text-muted-foreground text-sm">-</span> + ); + }, + size: 90, + }, + { + accessorKey: "engPicName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설계담당" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + {row.getValue("engPicName") || "-"} + </div> + ), + size: 100, + }, + { + accessorKey: "purchasePicName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구매담당" /> + ), + cell: ({ row }) => ( + <div className="text-sm"> + {row.getValue("purchasePicName") || "-"} + </div> + ), + size: 100, + }, + { + accessorKey: "estimatedBudget", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="예상예산" /> + ), + cell: ({ row }) => ( + <div className="text-sm font-mono"> + {row.getValue("estimatedBudget") || "-"} + </div> + ), + size: 100, + }, + { + accessorKey: "requestedDeliveryDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="희망납기" /> + ), + cell: ({ row }) => { + const date = row.getValue("requestedDeliveryDate") as Date | null; + return ( + <div className="text-sm"> + {date ? format(new Date(date), "yyyy-MM-dd", { locale: ko }) : "-"} + </div> + ); + }, + size: 100, + }, + { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="등록일" /> + ), + cell: ({ row }) => { + const date = row.getValue("createdAt") as Date; + return ( + <div className="text-sm text-muted-foreground"> + {date ? format(new Date(date), "yyyy-MM-dd", { locale: ko }) : "-"} + </div> + ); + }, + size: 100, + }, + { + id: "actions", + header: "작업", + cell: ({ row }) => { + const status = row.original.status; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <MoreHorizontal className="h-4 w-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuLabel>작업</DropdownMenuLabel> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "view" })} + > + <Eye className="mr-2 h-4 w-4" /> + 상세보기 + </DropdownMenuItem> + <DropdownMenuSeparator /> + + + + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "update" })} + > + <Edit className="mr-2 h-4 w-4" /> + 수정 + </DropdownMenuItem> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "delete" })} + className="text-destructive" + > + <Trash2 className="mr-2 h-4 w-4" /> + 삭제 + </DropdownMenuItem> + + + + +{status ==="작성중" && +<> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => setRowAction({ row, type: "createRfq" })} + className="text-primary" + > + <FileText className="mr-2 h-4 w-4" /> + RFQ 생성 + </DropdownMenuItem> + </> + } + + </DropdownMenuContent> + </DropdownMenu> + + ); + }, + size: 80, + }, + ]; +}
\ No newline at end of file diff --git a/lib/itb/table/purchase-requests-table.tsx b/lib/itb/table/purchase-requests-table.tsx new file mode 100644 index 00000000..88f27666 --- /dev/null +++ b/lib/itb/table/purchase-requests-table.tsx @@ -0,0 +1,229 @@ +// components/purchase-requests/purchase-requests-table.tsx +"use client"; + +import * as React from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Plus, RefreshCw, FileText } from "lucide-react"; +import { DataTable } from "@/components/data-table/data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import { useDataTable } from "@/hooks/use-data-table"; +import { getPurchaseRequestColumns } from "./purchase-request-columns"; +import { CreatePurchaseRequestDialog } from "./create-purchase-request-dialog"; +import { EditPurchaseRequestSheet } from "./edit-purchase-request-sheet"; +import { DeletePurchaseRequestDialog } from "./delete-purchase-request-dialog"; +import { ViewPurchaseRequestSheet } from "./view-purchase-request-sheet"; +// import { AttachmentsDialog } from "./attachments-dialog"; +import { ItemsDialog } from "./items-dialog"; +import type { DataTableRowAction } from "@/types/table"; +import type { PurchaseRequestView } from "@/db/schema"; +import { CreateRfqDialog } from "./create-rfq-dialog"; +import { toast } from "sonner"; + +interface PurchaseRequestsTableProps { + promises: Promise<[ + { + data: PurchaseRequestView[]; + pageCount: number; + }, + any + ]>; +} + +export function PurchaseRequestsTable({ promises }: PurchaseRequestsTableProps) { + const router = useRouter(); + const [{ data, pageCount }] = React.use(promises); + const [isCreateDialogOpen, setIsCreateDialogOpen] = React.useState(false); + const [isCreateRfqDialogOpen, setIsCreateRfqDialogOpen] = React.useState(false); + const [selectedRequestsForRfq, setSelectedRequestsForRfq] = React.useState<PurchaseRequestView[]>([]); + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PurchaseRequestView> | null>(null); + + const columns = React.useMemo( + () => getPurchaseRequestColumns({ setRowAction }), + [setRowAction] + ); + + const { table } = useDataTable({ + data, + columns, + pageCount, + enablePinning: true, + enableAdvancedFilter: true, + defaultPerPage: 10, + defaultSort: [{ id: "createdAt", desc: true }], + }); + + const refreshData = () => { + router.refresh(); + }; + + // 선택된 행들 가져오기 + const selectedRows = table.getSelectedRowModel().rows + .map(row => row.original) + .filter(row => row.status === "작성중") + ; + + // 선택된 행들로 RFQ 생성 다이얼로그 열기 + const handleBulkCreateRfq = () => { + const selectedRequests = selectedRows + + if (selectedRequests.length === 0) { + toast.error("RFQ를 생성할 구매 요청을 선택해주세요"); + return; + } + + setSelectedRequestsForRfq(selectedRequests); + setIsCreateRfqDialogOpen(true); + }; + + // 개별 행 액션 처리 + React.useEffect(() => { + if (rowAction?.type === "createRfq") { + setSelectedRequestsForRfq([rowAction.row.original]); + setIsCreateRfqDialogOpen(true); + setRowAction(null); + } + }, [rowAction]); + + // RFQ 생성 성공 후 처리 + const handleRfqSuccess = () => { + table.resetRowSelection(); // 선택 초기화 + refreshData(); + }; + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={[ + { + id: "requestCode", + label: "요청번호", + placeholder: "PR-2025-00001" + }, + { + id: "requestTitle", + label: "요청제목", + placeholder: "요청 제목 검색" + }, + { + id: "projectName", + label: "프로젝트명", + placeholder: "프로젝트명 검색" + }, + { + id: "packageName", + label: "패키지명", + placeholder: "패키지명 검색" + }, + { + id: "status", + label: "상태", + options: [ + { label: "작성중", value: "작성중" }, + { label: "RFQ생성완료", value: "RFQ생성완료" }, + ] + }, + { + id: "engPicName", + label: "설계 담당자", + placeholder: "담당자명 검색" + }, + { + id: "purchasePicName", + label: "구매 담당자", + placeholder: "담당자명 검색" + }, + ]} + > + <div className="flex items-center gap-2"> + <Button + size="sm" + onClick={() => setIsCreateDialogOpen(true)} + > + <Plus className="mr-2 h-4 w-4" /> + 새 구매요청 + </Button> + + {/* 선택된 항목들로 RFQ 일괄 생성 버튼 */} + {selectedRows.length > 0 && ( + <Button + size="sm" + variant="secondary" + onClick={handleBulkCreateRfq} + > + <FileText className="mr-2 h-4 w-4" /> + RFQ 생성 ({selectedRows.length}개) + </Button> + )} + + <Button + variant="outline" + size="sm" + onClick={refreshData} + > + <RefreshCw className="h-4 w-4" /> + </Button> + </div> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 다이얼로그들 */} + <CreatePurchaseRequestDialog + open={isCreateDialogOpen} + onOpenChange={setIsCreateDialogOpen} + onSuccess={refreshData} + /> + + {/* RFQ 생성 다이얼로그 */} + <CreateRfqDialog + requests={selectedRequestsForRfq} + open={isCreateRfqDialogOpen} + onOpenChange={(open) => { + setIsCreateRfqDialogOpen(open); + if (!open) { + setSelectedRequestsForRfq([]); + } + }} + onSuccess={handleRfqSuccess} + /> + + {rowAction?.type === "view" && ( + <ViewPurchaseRequestSheet + request={rowAction.row.original} + open={true} + onOpenChange={() => setRowAction(null)} + /> + )} + + {rowAction?.type === "update" && ( + <EditPurchaseRequestSheet + request={rowAction.row.original} + open={true} + onOpenChange={() => setRowAction(null)} + onSuccess={refreshData} + /> + )} + + {rowAction?.type === "delete" && ( + <DeletePurchaseRequestDialog + request={rowAction.row.original} + open={true} + onOpenChange={() => setRowAction(null)} + onSuccess={refreshData} + /> + )} + + {rowAction?.type === "items" && ( + <ItemsDialog + requestId={rowAction.row.original.id} + requestCode={rowAction.row.original.requestCode} + items={rowAction.row.original.items} + open={true} + onOpenChange={() => setRowAction(null)} + /> + )} + </> + ); +}
\ No newline at end of file diff --git a/lib/itb/table/view-purchase-request-sheet.tsx b/lib/itb/table/view-purchase-request-sheet.tsx new file mode 100644 index 00000000..c4ff9416 --- /dev/null +++ b/lib/itb/table/view-purchase-request-sheet.tsx @@ -0,0 +1,809 @@ +// components/purchase-requests/view-purchase-request-sheet.tsx +"use client"; + +import * as React from "react"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetFooter, +} from "@/components/ui/sheet"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + TableFooter, +} from "@/components/ui/table"; +import { Separator } from "@/components/ui/separator"; +import { + FileList, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list"; +import { + FileText, + Package, + Edit, + Download, + Calendar, + User, + Building, + MapPin, + Hash, + DollarSign, + Clock, + CheckCircle, + XCircle, + AlertCircle, + Layers, + Tag, + Paperclip, + FileIcon, + Eye +} from "lucide-react"; +import { format } from "date-fns"; +import { ko } from "date-fns/locale"; +import type { PurchaseRequestView } from "@/db/schema"; +import { useRouter } from "next/navigation"; +import { getPurchaseRequestAttachments } from "../service"; +import { downloadFile, quickPreview, formatFileSize, getFileInfo } from "@/lib/file-download"; + +interface ViewPurchaseRequestSheetProps { + request: PurchaseRequestView; + open: boolean; + onOpenChange: (open: boolean) => void; + onEditClick?: () => void; +} + +const statusConfig = { + "작성중": { + variant: "secondary" as const, + color: "text-gray-500", + icon: Edit, + bgColor: "bg-gray-100" + }, + "요청완료": { + variant: "default" as const, + color: "text-blue-500", + icon: CheckCircle, + bgColor: "bg-blue-50" + }, + "검토중": { + variant: "warning" as const, + color: "text-yellow-500", + icon: Clock, + bgColor: "bg-yellow-50" + }, + "승인": { + variant: "success" as const, + color: "text-green-500", + icon: CheckCircle, + bgColor: "bg-green-50" + }, + "반려": { + variant: "destructive" as const, + color: "text-red-500", + icon: XCircle, + bgColor: "bg-red-50" + }, + "RFQ생성완료": { + variant: "outline" as const, + color: "text-purple-500", + icon: Package, + bgColor: "bg-purple-50" + }, +}; + +export function ViewPurchaseRequestSheet({ + request, + open, + onOpenChange, + onEditClick, +}: ViewPurchaseRequestSheetProps) { + const router = useRouter(); + const [activeTab, setActiveTab] = React.useState("overview"); + const [attachments, setAttachments] = React.useState<any[]>([]); + const [isLoadingFiles, setIsLoadingFiles] = React.useState(false); + + // 첨부파일 로드 + React.useEffect(() => { + async function loadAttachments() { + if (open && request.id) { + setIsLoadingFiles(true); + try { + const result = await getPurchaseRequestAttachments(request.id); + if (result.success && result.data) { + setAttachments(result.data); + } else { + console.error("Failed to load attachments:", result.error); + setAttachments([]); + } + } catch (error) { + console.error("Error loading attachments:", error); + setAttachments([]); + } finally { + setIsLoadingFiles(false); + } + } + } + + loadAttachments(); + }, [open, request.id]); + + // 파일 다운로드 핸들러 + const handleFileDownload = async (file: any) => { + const result = await downloadFile(file.filePath, file.originalFileName, { + action: 'download', + showToast: true, + onError: (error) => { + console.error("Download failed:", error); + }, + onSuccess: (fileName, fileSize) => { + console.log(`Successfully downloaded: ${fileName} (${fileSize} bytes)`); + } + }); + + return result; + }; + + // 파일 미리보기 핸들러 + const handleFilePreview = async (file: any) => { + const fileInfo = getFileInfo(file.originalFileName); + + if (!fileInfo.canPreview) { + // 미리보기가 지원되지 않는 파일은 다운로드 + return handleFileDownload(file); + } + + const result = await quickPreview(file.filePath, file.originalFileName); + return result; + }; + + // 전체 다운로드 핸들러 + const handleDownloadAll = async () => { + for (const file of attachments) { + await handleFileDownload(file); + // 여러 파일 다운로드 시 간격 두기 + await new Promise(resolve => setTimeout(resolve, 500)); + } + }; + + // 아이템 총액 계산 + const totalAmount = React.useMemo(() => { + if (!request.items || !Array.isArray(request.items)) return 0; + return request.items.reduce((sum, item) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return sum + subtotal; + }, 0); + }, [request.items]); + + const handleEdit = () => { + if (onEditClick) { + onEditClick(); + } else { + onOpenChange(false); + } + }; + + const handleExport = () => { + // Export to Excel 기능 + console.log("Export to Excel"); + }; + + const StatusIcon = statusConfig[request.status]?.icon || AlertCircle; + const statusBgColor = statusConfig[request.status]?.bgColor || "bg-gray-50"; + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="w-[900px] max-w-[900px] overflow-hidden flex flex-col min-h-0" style={{width:900 , maxWidth:900}}> + <SheetHeader className="flex-shrink-0"> + <div className="flex items-center justify-between"> + <SheetTitle>구매요청 상세</SheetTitle> + <div className={`flex items-center gap-2 px-3 py-1.5 rounded-full ${statusBgColor}`}> + <StatusIcon className={`h-4 w-4 ${statusConfig[request.status]?.color}`} /> + <span className={`text-sm font-medium ${statusConfig[request.status]?.color}`}> + {request.status} + </span> + </div> + </div> + <SheetDescription> + 요청번호: <span className="font-mono font-medium">{request.requestCode}</span> | + 작성일: {request.createdAt && format(new Date(request.createdAt), "yyyy-MM-dd")} + </SheetDescription> + </SheetHeader> + + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col min-h-0"> + <TabsList className="grid w-full grid-cols-4 flex-shrink-0"> + <TabsTrigger value="overview"> + <FileText className="mr-2 h-4 w-4" /> + 개요 + </TabsTrigger> + <TabsTrigger value="items"> + <Package className="mr-2 h-4 w-4" /> + 품목 정보 + {request.itemCount > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {request.itemCount} + </span> + )} + </TabsTrigger> + <TabsTrigger value="files"> + <Paperclip className="mr-2 h-4 w-4" /> + 첨부파일 + {attachments.length > 0 && ( + <span className="ml-2 rounded-full bg-primary px-2 py-0.5 text-xs text-primary-foreground"> + {attachments.length} + </span> + )} + </TabsTrigger> + <TabsTrigger value="history"> + <Clock className="mr-2 h-4 w-4" /> + 처리 이력 + </TabsTrigger> + </TabsList> + + <div className="flex-1 overflow-y-auto px-1 min-h-0"> + <TabsContent value="overview" className="space-y-6 mt-6"> + {/* 기본 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">기본 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div> + <p className="text-sm text-muted-foreground mb-1">요청 제목</p> + <p className="text-lg font-semibold">{request.requestTitle}</p> + </div> + + {request.requestDescription && ( + <div> + <p className="text-sm text-muted-foreground mb-1">요청 설명</p> + <p className="text-sm bg-muted/30 p-3 rounded-lg">{request.requestDescription}</p> + </div> + )} + + <Separator /> + + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-center gap-3"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm text-muted-foreground">희망 납기일</p> + <p className="font-medium"> + {request.requestedDeliveryDate + ? format(new Date(request.requestedDeliveryDate), "yyyy-MM-dd") + : "-"} + </p> + </div> + </div> + + <div className="flex items-center gap-3"> + <DollarSign className="h-4 w-4 text-muted-foreground" /> + <div> + <p className="text-sm text-muted-foreground">예상 예산</p> + <p className="font-medium">{request.estimatedBudget || "-"}</p> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 프로젝트 & 패키지 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">프로젝트 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <Hash className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">프로젝트 코드</p> + <p className="font-medium">{request.projectCode || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <FileText className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">프로젝트명</p> + <p className="font-medium">{request.projectName || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <Building className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">발주처</p> + <p className="font-medium">{request.projectCompany || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <MapPin className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">현장</p> + <p className="font-medium">{request.projectSite || "-"}</p> + </div> + </div> + </div> + + <Separator /> + + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <Package className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">패키지</p> + <p className="font-medium">{request.packageNo} - {request.packageName || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <Tag className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">SM 코드</p> + <p className="font-medium">{request.smCode || "-"}</p> + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 자재 정보 */} + {(request.majorItemMaterialCategory || request.majorItemMaterialDescription) && ( + <Card> + <CardHeader> + <CardTitle className="text-base">자재 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <Layers className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">자재 그룹</p> + <p className="font-medium">{request.majorItemMaterialCategory || "-"}</p> + </div> + </div> + + <div className="flex items-start gap-3"> + <FileText className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">자재 설명</p> + <p className="font-medium">{request.majorItemMaterialDescription || "-"}</p> + </div> + </div> + </div> + </CardContent> + </Card> + )} + + {/* 담당자 정보 */} + <Card> + <CardHeader> + <CardTitle className="text-base">담당자 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-4"> + <div className="flex items-start gap-3"> + <User className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">설계 담당자</p> + <p className="font-medium">{request.engPicName || "-"}</p> + {request.engPicEmail && ( + <p className="text-sm text-muted-foreground">{request.engPicEmail}</p> + )} + </div> + </div> + + <div className="flex items-start gap-3"> + <User className="h-4 w-4 text-muted-foreground mt-0.5" /> + <div> + <p className="text-sm text-muted-foreground">구매 담당자</p> + <p className="font-medium">{request.purchasePicName || "미배정"}</p> + {request.purchasePicEmail && ( + <p className="text-sm text-muted-foreground">{request.purchasePicEmail}</p> + )} + </div> + </div> + </div> + </CardContent> + </Card> + + {/* 반려 사유 */} + {request.status === "반려" && request.rejectReason && ( + <Card className="border-destructive"> + <CardHeader className="bg-destructive/5"> + <CardTitle className="text-base text-destructive flex items-center gap-2"> + <XCircle className="h-4 w-4" /> + 반려 사유 + </CardTitle> + </CardHeader> + <CardContent className="pt-4"> + <p className="text-sm">{request.rejectReason}</p> + </CardContent> + </Card> + )} + </TabsContent> + + <TabsContent value="items" className="mt-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="text-base">품목 목록</CardTitle> + <CardDescription className="mt-1"> + 총 {request.itemCount || 0}개 품목 | 총 수량 {request.totalQuantity || 0}개 + </CardDescription> + </div> + <Button variant="outline" size="sm" onClick={handleExport}> + <Download className="mr-2 h-4 w-4" /> + Excel 다운로드 + </Button> + </div> + </CardHeader> + <CardContent> + {(!request.items || request.items.length === 0) ? ( + <div className="flex flex-col items-center justify-center h-32 text-muted-foreground"> + <Package className="h-8 w-8 mb-2" /> + <p>등록된 품목이 없습니다</p> + </div> + ) : ( + <div className="border rounded-lg"> + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-[50px] text-center">번호</TableHead> + <TableHead>아이템 코드</TableHead> + <TableHead>아이템명</TableHead> + <TableHead>사양</TableHead> + <TableHead className="text-right">수량</TableHead> + <TableHead className="text-center">단위</TableHead> + <TableHead className="text-right">예상 단가</TableHead> + <TableHead className="text-right">예상 금액</TableHead> + {request.items[0]?.remarks && <TableHead>비고</TableHead>} + </TableRow> + </TableHeader> + <TableBody> + {request.items.map((item: any, index: number) => { + const subtotal = (item.quantity || 0) * (item.estimatedUnitPrice || 0); + return ( + <TableRow key={item.id || index}> + <TableCell className="text-center">{index + 1}</TableCell> + <TableCell className="font-mono text-sm">{item.itemCode || "-"}</TableCell> + <TableCell className="font-medium">{item.itemName}</TableCell> + <TableCell className="text-sm">{item.specification || "-"}</TableCell> + <TableCell className="text-right font-medium"> + {item.quantity?.toLocaleString('ko-KR')} + </TableCell> + <TableCell className="text-center">{item.unit}</TableCell> + <TableCell className="text-right"> + {item.estimatedUnitPrice + ? item.estimatedUnitPrice.toLocaleString('ko-KR') + "원" + : "-"} + </TableCell> + <TableCell className="text-right font-medium"> + {subtotal > 0 + ? subtotal.toLocaleString('ko-KR') + "원" + : "-"} + </TableCell> + {request.items[0]?.remarks && ( + <TableCell className="text-sm">{item.remarks || "-"}</TableCell> + )} + </TableRow> + ); + })} + </TableBody> + <TableFooter> + <TableRow> + <TableCell colSpan={6} className="text-right font-medium"> + 총 합계 + </TableCell> + <TableCell colSpan={2} className="text-right"> + <div className="flex flex-col"> + <span className="text-2xl font-bold text-primary"> + {new Intl.NumberFormat('ko-KR', { + style: 'currency', + currency: 'KRW' + }).format(totalAmount)} + </span> + </div> + </TableCell> + {request.items[0]?.remarks && <TableCell />} + </TableRow> + </TableFooter> + </Table> + </div> + )} + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="files" className="mt-6"> + <Card> + <CardHeader> + <div className="flex items-center justify-between"> + <div> + <CardTitle className="text-base">첨부파일</CardTitle> + <CardDescription className="mt-1"> + 구매 요청 관련 문서 및 파일 + </CardDescription> + </div> + </div> + </CardHeader> + <CardContent> + {isLoadingFiles ? ( + <div className="flex items-center justify-center h-32"> + <p className="text-muted-foreground">파일 목록을 불러오는 중...</p> + </div> + ) : attachments.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-32 text-muted-foreground"> + <Paperclip className="h-8 w-8 mb-2" /> + <p>첨부된 파일이 없습니다</p> + </div> + ) : ( + <div className="space-y-4"> + <div className="flex items-center justify-between mb-4"> + <p className="text-sm text-muted-foreground"> + 총 {attachments.length}개의 파일이 첨부되어 있습니다 + </p> + <Button + variant="outline" + size="sm" + onClick={handleDownloadAll} + disabled={attachments.length === 0} + > + <Download className="mr-2 h-4 w-4" /> + 전체 다운로드 + </Button> + </div> + + <FileList> + {attachments.map((file, index) => { + const fileInfo = getFileInfo(file.originalFileName || file.fileName); + return ( + <FileListItem key={file.id || file.fileName || index}> + <FileListIcon> + <FileIcon className="h-4 w-4" /> + </FileListIcon> + <FileListInfo> + <FileListHeader> + <FileListName> + {file.originalFileName || file.fileName} + </FileListName> + <FileListSize> + {file.fileSize} + </FileListSize> + </FileListHeader> + <FileListDescription className="flex items-center gap-4"> + {file.createdAt && ( + <span className="text-xs"> + {format(new Date(file.createdAt), "yyyy-MM-dd HH:mm")} + </span> + )} + {file.category && ( + <Badge variant="secondary" className="text-xs"> + {file.category} + </Badge> + )} + </FileListDescription> + </FileListInfo> + <div className="flex items-center gap-1"> + {fileInfo.canPreview && ( + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleFilePreview(file)} + title="미리보기" + > + <Eye className="h-4 w-4" /> + </Button> + )} + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={() => handleFileDownload(file)} + title="다운로드" + > + <Download className="h-4 w-4" /> + </Button> + </div> + </FileListItem> + ); + })} + </FileList> + + {/* 파일 종류별 요약 */} + {attachments.length > 0 && ( + <div className="mt-6 p-4 bg-muted/30 rounded-lg"> + <h4 className="text-sm font-medium mb-3">파일 요약</h4> + <div className="grid grid-cols-3 gap-4 text-sm"> + <div> + <p className="text-muted-foreground">총 파일 수</p> + <p className="font-medium">{attachments.length}개</p> + </div> + <div> + <p className="text-muted-foreground">총 용량</p> + <p className="font-medium"> + {formatFileSize( + attachments.reduce((sum, file) => sum + (file.fileSize || 0), 0) + )} + </p> + </div> + <div> + <p className="text-muted-foreground">최근 업로드</p> + <p className="font-medium"> + {attachments[0]?.createdAt + ? format(new Date(attachments[0].createdAt), "yyyy-MM-dd") + : "-"} + </p> + </div> + </div> + </div> + )} + </div> + )} + </CardContent> + </Card> + </TabsContent> + + <TabsContent value="history" className="mt-6"> + <Card> + <CardHeader> + <CardTitle className="text-base">처리 이력</CardTitle> + <CardDescription>요청서의 처리 현황을 시간순으로 확인할 수 있습니다</CardDescription> + </CardHeader> + <CardContent> + <div className="relative"> + <div className="absolute left-3 top-0 bottom-0 w-0.5 bg-border" /> + + <div className="space-y-6"> + {/* 생성 */} + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-primary flex items-center justify-center"> + <div className="h-2 w-2 rounded-full bg-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">요청서 작성</p> + <p className="text-sm text-muted-foreground"> + {request.createdByName} ({request.createdByEmail}) + </p> + <p className="text-xs text-muted-foreground mt-1"> + {request.createdAt && format(new Date(request.createdAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + + {/* 확정 */} + {request.confirmedAt && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-blue-500 flex items-center justify-center"> + <CheckCircle className="h-3 w-3 text-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">요청 확정</p> + <p className="text-sm text-muted-foreground"> + {request.confirmedByName} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {format(new Date(request.confirmedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + + {/* 반려 */} + {request.status === "반려" && request.rejectReason && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-destructive flex items-center justify-center"> + <XCircle className="h-3 w-3 text-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium text-destructive">반려됨</p> + <p className="text-sm text-muted-foreground"> + {request.rejectReason} + </p> + <p className="text-xs text-muted-foreground mt-1"> + {request.updatedAt && format(new Date(request.updatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + + {/* RFQ 생성 */} + {request.rfqCreatedAt && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-green-500 flex items-center justify-center"> + <Package className="h-3 w-3 text-white" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">RFQ 생성 완료</p> + <p className="text-sm text-muted-foreground"> + RFQ 번호: <span className="font-mono font-medium">{request.rfqCode}</span> + </p> + <p className="text-xs text-muted-foreground mt-1"> + {format(new Date(request.rfqCreatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + + {/* 최종 수정 */} + {request.updatedAt && request.updatedAt !== request.createdAt && !request.rfqCreatedAt && request.status !== "반려" && ( + <div className="flex gap-4"> + <div className="relative"> + <div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center"> + <Edit className="h-3 w-3 text-muted-foreground" /> + </div> + </div> + <div className="flex-1 -mt-0.5"> + <p className="font-medium">최종 수정</p> + <p className="text-sm text-muted-foreground"> + {request.updatedByName} ({request.updatedByEmail}) + </p> + <p className="text-xs text-muted-foreground mt-1"> + {format(new Date(request.updatedAt), "yyyy-MM-dd HH:mm:ss", { locale: ko })} + </p> + </div> + </div> + )} + </div> + </div> + </CardContent> + </Card> + </TabsContent> + </div> + </Tabs> + + <SheetFooter className="mt-6 flex-shrink-0"> + <div className="flex w-full justify-between"> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + {request.status === "작성중" && ( + <Button onClick={handleEdit}> + <Edit className="mr-2 h-4 w-4" /> + 수정하기 + </Button> + )} + </div> + </SheetFooter> + </SheetContent> + </Sheet> + ); +}
\ No newline at end of file |
