diff options
Diffstat (limited to 'lib')
43 files changed, 5861 insertions, 2649 deletions
diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx index 635993fb..759f7cac 100644 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -177,8 +177,11 @@ const canCompleteCurrentContract = React.useMemo(() => { const handleOpenChange = (isOpen: boolean) => { if (!isOpen && !allCompleted && completedCount > 0) { // 완료되지 않은 계약서가 있으면 확인 대화상자 + // const confirmClose = window.confirm( + // `${completedCount}/${totalCount}개 계약서가 완료되었습니다. 정말 나가시겠습니까?` + // ); const confirmClose = window.confirm( - `${completedCount}/${totalCount}개 계약서가 완료되었습니다. 정말 나가시겠습니까?` + `정말 나가시겠습니까?` ); if (!confirmClose) return; } @@ -618,7 +621,7 @@ const canCompleteCurrentContract = React.useMemo(() => { )} {dialogTitle} {/* 진행 상황 표시 */} - <Badge + {/* <Badge variant="outline" className={cn( "ml-3", @@ -628,7 +631,7 @@ const canCompleteCurrentContract = React.useMemo(() => { )} > {completedCount}/{totalCount} 완료 - </Badge> + </Badge> */} {/* 추가 파일 로딩 표시 */} {isLoadingAttachments && ( <Loader2 className="ml-2 h-4 w-4 animate-spin text-blue-500" /> diff --git a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx index c1471a69..d0f85b14 100644 --- a/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx +++ b/lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx @@ -79,7 +79,7 @@ export function BiddingDetailVendorCreateDialog({ // 벤더 로드 const loadVendors = React.useCallback(async () => { try { - const result = await searchVendorsForBidding('', biddingId, 50) // 빈 검색어로 모든 벤더 로드 + const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드 setVendorList(result || []) } catch (error) { console.error('Failed to load vendors:', error) diff --git a/lib/bidding/list/create-bidding-dialog.tsx b/lib/bidding/list/create-bidding-dialog.tsx index cb91a984..e99ac06f 100644 --- a/lib/bidding/list/create-bidding-dialog.tsx +++ b/lib/bidding/list/create-bidding-dialog.tsx @@ -137,6 +137,7 @@ export function CreateBiddingDialog() { const [activeTab, setActiveTab] = React.useState<TabType>("basic") const [showSuccessDialog, setShowSuccessDialog] = React.useState(false) // 추가 const [createdBiddingId, setCreatedBiddingId] = React.useState<number | null>(null) // 추가 + const [showCloseConfirmDialog, setShowCloseConfirmDialog] = React.useState(false) // 닫기 확인 다이얼로그 상태 // Procurement 데이터 상태들 const [paymentTermsOptions, setPaymentTermsOptions] = React.useState<Array<{code: string, description: string}>>([]) @@ -686,9 +687,23 @@ export function CreateBiddingDialog() { // 다이얼로그 핸들러 function handleDialogOpenChange(nextOpen: boolean) { if (!nextOpen) { + // 닫으려 할 때 확인 창을 먼저 띄움 + setShowCloseConfirmDialog(true) + } else { + // 열 때는 바로 적용 + setOpen(nextOpen) + } + } + + // 닫기 확인 핸들러 + const handleCloseConfirm = (confirmed: boolean) => { + setShowCloseConfirmDialog(false) + if (confirmed) { + // 사용자가 "예"를 선택한 경우 실제로 닫기 resetAllStates() + setOpen(false) } - setOpen(nextOpen) + // "아니오"를 선택한 경우는 아무것도 하지 않음 (다이얼로그 유지) } // 입찰 생성 버튼 클릭 핸들러 추가 @@ -2172,10 +2187,7 @@ export function CreateBiddingDialog() { <Button type="button" variant="outline" - onClick={() => { - resetAllStates() - setOpen(false) - }} + onClick={() => setShowCloseConfirmDialog(true)} disabled={isSubmitting} > 취소 @@ -2227,6 +2239,27 @@ export function CreateBiddingDialog() { </DialogContent> </Dialog> + {/* 닫기 확인 다이얼로그 */} + <AlertDialog open={showCloseConfirmDialog} onOpenChange={setShowCloseConfirmDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle>입찰 생성을 취소하시겠습니까?</AlertDialogTitle> + <AlertDialogDescription> + 현재 입력 중인 내용이 모두 삭제되며, 생성되지 않습니다. + 정말로 취소하시겠습니까? + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel onClick={() => handleCloseConfirm(false)}> + 아니오 (계속 입력) + </AlertDialogCancel> + <AlertDialogAction onClick={() => handleCloseConfirm(true)}> + 예 (취소) + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + <AlertDialog open={showSuccessDialog} onOpenChange={setShowSuccessDialog}> <AlertDialogContent> <AlertDialogHeader> diff --git a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx index 9ca7deb6..bd078192 100644 --- a/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx +++ b/lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx @@ -68,7 +68,7 @@ export function BiddingPreQuoteVendorCreateDialog({ // 벤더 로드 const loadVendors = React.useCallback(async () => { try { - const result = await searchVendorsForBidding('', biddingId, 50) // 빈 검색어로 모든 벤더 로드 + const result = await searchVendorsForBidding('', biddingId) // 빈 검색어로 모든 벤더 로드 setVendorList(result || []) } catch (error) { console.error('Failed to load vendors:', error) diff --git a/lib/bidding/service.ts b/lib/bidding/service.ts index 68efe165..8cbe2a2b 100644 --- a/lib/bidding/service.ts +++ b/lib/bidding/service.ts @@ -1381,7 +1381,7 @@ export async function getActiveContractTemplates() { } // 입찰에 참여하지 않은 벤더만 검색 (중복 방지) -export async function searchVendorsForBidding(searchTerm: string = "", biddingId: number, limit: number = 100) { +export async function searchVendorsForBidding(searchTerm: string = "", biddingId: number) { try { let whereCondition; @@ -1419,8 +1419,8 @@ export async function searchVendorsForBidding(searchTerm: string = "", biddingId // eq(vendorsWithTypesView.status, "ACTIVE"), ) ) - .orderBy(asc(vendorsWithTypesView.vendorName)) - .limit(limit); + .orderBy(asc(vendorsWithTypesView.vendorName)); + return result; } catch (error) { diff --git a/lib/docu-list-rule/number-type-configs/service.ts b/lib/docu-list-rule/number-type-configs/service.ts index c29af464..b644c43a 100644 --- a/lib/docu-list-rule/number-type-configs/service.ts +++ b/lib/docu-list-rule/number-type-configs/service.ts @@ -166,12 +166,12 @@ export async function getNumberTypeConfigs(input: GetNumberTypeConfigsSchema) { } } -// Number Type Config 생성 export async function createNumberTypeConfig(input: { documentNumberTypeId: number codeGroupId: number | null sdq: number description?: string + delimiter?: string remark?: string }) { try { @@ -198,6 +198,7 @@ export async function createNumberTypeConfig(input: { codeGroupId: input.codeGroupId, sdq: input.sdq, description: input.description, + delimiter: input.delimiter, remark: input.remark, }) .returning({ id: documentNumberTypeConfigs.id }) @@ -218,12 +219,12 @@ export async function createNumberTypeConfig(input: { } } -// Number Type Config 수정 export async function updateNumberTypeConfig(input: { id: number codeGroupId: number | null sdq: number description?: string + delimiter?: string remark?: string }) { try { @@ -263,6 +264,7 @@ export async function updateNumberTypeConfig(input: { codeGroupId: input.codeGroupId, sdq: input.sdq, description: input.description, + delimiter: input.delimiter, remark: input.remark, updatedAt: new Date(), }) @@ -284,7 +286,6 @@ export async function updateNumberTypeConfig(input: { } } } - // Number Type Config 순서 변경 (간단한 방식) export async function updateNumberTypeConfigOrder(input: { id: number diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx index cd2d6fc8..ad3478ff 100644 --- a/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx +++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx @@ -2,6 +2,9 @@ import * as React from "react" import { Loader2 } from "lucide-react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -14,6 +17,14 @@ import { DialogTitle, } from "@/components/ui/dialog" import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Select, SelectContent, SelectItem, @@ -21,18 +32,30 @@ import { SelectValue, } from "@/components/ui/select" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { updateNumberTypeConfig, getActiveCodeGroups } from "@/lib/docu-list-rule/number-type-configs/service" import { NumberTypeConfig } from "@/lib/docu-list-rule/types" +const formSchema = z.object({ + codeGroupId: z.string().min(1, "Code Group을 선택해주세요."), + sdq: z.string().min(1, "순서를 입력해주세요.").refine( + (val) => !isNaN(Number(val)) && Number(val) > 0, + { message: "순서는 1 이상의 숫자여야 합니다." } + ), + description: z.string().optional(), + delimiter: z.string().max(10).optional(), + remark: z.string().optional(), +}) + +type FormData = z.infer<typeof formSchema> + interface NumberTypeConfigsEditDialogProps { open: boolean onOpenChange: (open: boolean) => void data: NumberTypeConfig | null onSuccess?: () => void - existingConfigs?: NumberTypeConfig[] // 기존 configs 목록 추가 + existingConfigs?: NumberTypeConfig[] selectedProjectId?: number | null } @@ -41,29 +64,35 @@ export function NumberTypeConfigsEditDialog({ onOpenChange, data, onSuccess, - existingConfigs = [], // 기본값 추가 + existingConfigs = [], selectedProjectId, }: NumberTypeConfigsEditDialogProps) { const [isLoading, setIsLoading] = React.useState(false) const [codeGroups, setCodeGroups] = React.useState<{ id: number; description: string }[]>([]) - const [formData, setFormData] = React.useState({ - codeGroupId: "", - sdq: "", - description: "", - remark: "" + + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + codeGroupId: "", + sdq: "", + description: "", + delimiter: "", + remark: "", + }, }) // 데이터가 변경될 때 폼 초기화 React.useEffect(() => { if (data) { - setFormData({ - codeGroupId: data.codeGroupId?.toString() || "", // null 체크 추가 + form.reset({ + codeGroupId: data.codeGroupId?.toString() || "", sdq: data.sdq.toString(), description: data.description || "", + delimiter: data.delimiter || "", remark: data.remark || "" }) } - }, [data]) + }, [data, form]) // Code Groups 로드 React.useEffect(() => { @@ -79,21 +108,23 @@ export function NumberTypeConfigsEditDialog({ })() }, [selectedProjectId]) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!data || !formData.codeGroupId || !formData.sdq) { - toast.error("필수 필드를 모두 입력해주세요.") + const onSubmit = async (values: FormData) => { + if (!data) { + toast.error("데이터를 찾을 수 없습니다.") return } - const newSdq = parseInt(formData.sdq) + const newSdq = parseInt(values.sdq) // 순서 중복 검증 (현재 수정 중인 항목 제외) const existingSdq = existingConfigs.find(config => config.sdq === newSdq && config.id !== data.id ) if (existingSdq) { - toast.error(`순서 ${newSdq}번은 이미 사용 중입니다. 다른 순서를 입력해주세요.`) + form.setError("sdq", { + type: "manual", + message: `순서 ${newSdq}번은 이미 사용 중입니다. 다른 순서를 입력해주세요.` + }) return } @@ -101,11 +132,11 @@ export function NumberTypeConfigsEditDialog({ try { const result = await updateNumberTypeConfig({ id: data.id, - codeGroupId: parseInt(formData.codeGroupId), - + codeGroupId: parseInt(values.codeGroupId), sdq: newSdq, - description: formData.description || undefined, - remark: formData.remark || undefined, + description: values.description || undefined, + delimiter: values.delimiter || undefined, + remark: values.remark || undefined, }) if (result.success) { @@ -135,91 +166,127 @@ export function NumberTypeConfigsEditDialog({ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> </DialogDescription> </DialogHeader> - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="grid gap-4 py-2"> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="codeGroup" className="text-right"> - Code Group <span className="text-red-500">*</span> - </Label> - <div className="col-span-3"> - <Select - value={formData.codeGroupId} - onValueChange={(value) => setFormData(prev => ({ ...prev, codeGroupId: value }))} - > - <SelectTrigger> - <SelectValue placeholder="Code Group 선택" /> - </SelectTrigger> - <SelectContent> - {codeGroups.map((codeGroup) => ( - <SelectItem key={codeGroup.id} value={codeGroup.id.toString()}> - {codeGroup.description} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="sdq" className="text-right"> - 순서 <span className="text-red-500">*</span> - </Label> - <div className="col-span-3"> - <Input - id="sdq" - type="number" - value={formData.sdq} - onChange={(e) => setFormData(prev => ({ ...prev, sdq: e.target.value }))} - min="1" - /> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="description" className="text-right"> - Description - </Label> - <div className="col-span-3"> - <Input - id="description" - value={formData.description} - onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} - placeholder="예: PROJECT NO" - /> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="remark" className="text-right"> - Remark - </Label> - <div className="col-span-3"> - <Textarea - id="remark" - value={formData.remark} - onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))} - placeholder="비고 사항" - rows={3} - /> - </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid gap-4 py-2"> + <FormField + control={form.control} + name="codeGroupId" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right"> + Code Group <span className="text-red-500">*</span> + </FormLabel> + <div className="col-span-3"> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger> + <SelectValue placeholder="Code Group 선택" /> + </SelectTrigger> + <SelectContent> + {codeGroups.map((codeGroup) => ( + <SelectItem key={codeGroup.id} value={codeGroup.id.toString()}> + {codeGroup.description} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="sdq" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right"> + 순서 <span className="text-red-500">*</span> + </FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} type="number" min="1" /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Description</FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} placeholder="예: PROJECT NO" /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="delimiter" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Delimiter</FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} placeholder="예: -, _, /" maxLength={10} /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Remark</FormLabel> + <div className="col-span-3"> + <FormControl> + <Textarea {...field} placeholder="비고 사항" rows={3} /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> </div> - </div> - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => onOpenChange(false)} - disabled={isLoading} - > - 취소 - </Button> - <Button - type="submit" - disabled={isLoading} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {isLoading ? "수정 중..." : "수정"} - </Button> - </DialogFooter> - </form> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "수정 중..." : "수정"} + </Button> + </DialogFooter> + </form> + </Form> </DialogContent> </Dialog> ) -}
\ No newline at end of file +}
\ No newline at end of file diff --git a/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx b/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx index 572d05cd..243dff73 100644 --- a/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx +++ b/lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx @@ -2,6 +2,9 @@ import * as React from "react" import { Plus, Loader2 } from "lucide-react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import * as z from "zod" import { Button } from "@/components/ui/button" import { Dialog, @@ -13,6 +16,14 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Select, SelectContent, SelectItem, @@ -20,7 +31,6 @@ import { SelectValue, } from "@/components/ui/select" import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" import { Textarea } from "@/components/ui/textarea" import { toast } from "sonner" @@ -28,6 +38,15 @@ import { createNumberTypeConfig, getActiveCodeGroups } from "@/lib/docu-list-rul import { DeleteNumberTypeConfigsDialog } from "@/lib/docu-list-rule/number-type-configs/table/delete-number-type-configs-dialog" import { NumberTypeConfig } from "@/lib/docu-list-rule/types" +const formSchema = z.object({ + codeGroupId: z.string().min(1, "Code Group을 선택해주세요."), + description: z.string().optional(), + delimiter: z.string().max(10).optional(), + remark: z.string().optional(), +}) + +type FormData = z.infer<typeof formSchema> + interface NumberTypeConfigsToolbarActionsProps { // eslint-disable-next-line @typescript-eslint/no-explicit-any table: any @@ -46,15 +65,23 @@ export function NumberTypeConfigsToolbarActions({ }: NumberTypeConfigsToolbarActionsProps) { const [isAddDialogOpen, setIsAddDialogOpen] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false) - const [formData, setFormData] = React.useState({ codeGroupId: "", description: "", remark: "" }) const [codeGroups, setCodeGroups] = React.useState<{ id: number; description: string }[]>([]) const [allOptions, setAllOptions] = React.useState<{ id: string; name: string }[]>([]) + const form = useForm<FormData>({ + resolver: zodResolver(formSchema), + defaultValues: { + codeGroupId: "", + description: "", + delimiter: "", + remark: "", + }, + }) + const loadCodeGroups = React.useCallback(async () => { try { const result = await getActiveCodeGroups(selectedProjectId || undefined) if (result.success && result.data) { - // 이미 추가된 Code Group들을 제외하고 필터링 const usedCodeGroupIds = configsData.map(config => config.codeGroupId) const availableCodeGroups = result.data.filter(codeGroup => @@ -86,17 +113,23 @@ export function NumberTypeConfigsToolbarActions({ combineOptions() }, [combineOptions]) - // 다이얼로그가 열릴 때마다 Code Groups 다시 로드 + // 다이얼로그가 열릴 때마다 Code Groups 다시 로드 및 폼 리셋 React.useEffect(() => { if (isAddDialogOpen) { loadCodeGroups() + form.reset() } - }, [isAddDialogOpen, loadCodeGroups, configsData]) + }, [isAddDialogOpen, loadCodeGroups, configsData, form]) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - if (!selectedNumberType || !formData.codeGroupId) { - toast.error("필수 필드를 모두 입력해주세요.") + const getNextSdq = () => { + if (configsData.length === 0) return 1 + const maxSdq = Math.max(...configsData.map(config => config.sdq)) + return maxSdq + 1 + } + + const onSubmit = async (values: FormData) => { + if (!selectedNumberType) { + toast.error("Number Type을 선택해주세요.") return } @@ -105,21 +138,21 @@ export function NumberTypeConfigsToolbarActions({ try { // Code Group ID 추출 - const codeGroupId = parseInt(formData.codeGroupId.replace('cg_', '')) + const codeGroupId = parseInt(values.codeGroupId.replace('cg_', '')) const result = await createNumberTypeConfig({ documentNumberTypeId: selectedNumberType, codeGroupId: codeGroupId, - sdq: sdq, - description: formData.description || undefined, - remark: formData.remark || undefined, + description: values.description || undefined, + delimiter: values.delimiter || undefined, + remark: values.remark || undefined, }) if (result.success) { toast.success("Number Type Config가 성공적으로 추가되었습니다.") setIsAddDialogOpen(false) - setFormData({ codeGroupId: "", description: "", remark: "" }) + form.reset() onSuccess?.() } else { toast.error(result.error || "추가에 실패했습니다.") @@ -132,12 +165,6 @@ export function NumberTypeConfigsToolbarActions({ } } - const getNextSdq = () => { - if (configsData.length === 0) return 1 - const maxSdq = Math.max(...configsData.map(config => config.sdq)) - return maxSdq + 1 - } - return ( <div className="flex items-center gap-2"> {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} @@ -170,84 +197,116 @@ export function NumberTypeConfigsToolbarActions({ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span> </DialogDescription> </DialogHeader> - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="grid gap-4 py-2"> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="codeGroup" className="text-right"> - Code Group <span className="text-red-500">*</span> - </Label> - <div className="col-span-3"> - <Select - value={formData.codeGroupId} - onValueChange={(value) => setFormData(prev => ({ ...prev, codeGroupId: value }))} - > - <SelectTrigger> - <SelectValue placeholder="Code Group 선택" /> - </SelectTrigger> - <SelectContent> - {allOptions.length > 0 ? ( - allOptions.map((option) => ( - <SelectItem key={option.id} value={option.id}> - {option.name} - </SelectItem> - )) - ) : ( - <div className="px-2 py-1.5 text-sm text-muted-foreground"> - 사용 가능한 옵션이 없습니다. - </div> - )} - </SelectContent> - </Select> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="description" className="text-right"> - Description - </Label> - <div className="col-span-3"> - <Input - id="description" - value={formData.description} - onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))} - placeholder="예: PROJECT NO" - /> - </div> - </div> - <div className="grid grid-cols-4 items-center gap-4"> - <Label htmlFor="remark" className="text-right"> - Remark - </Label> - <div className="col-span-3"> - <Textarea - id="remark" - value={formData.remark} - onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))} - placeholder="비고 사항" - rows={3} - /> - </div> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid gap-4 py-2"> + <FormField + control={form.control} + name="codeGroupId" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right"> + Code Group <span className="text-red-500">*</span> + </FormLabel> + <div className="col-span-3"> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger> + <SelectValue placeholder="Code Group 선택" /> + </SelectTrigger> + <SelectContent> + {allOptions.length > 0 ? ( + allOptions.map((option) => ( + <SelectItem key={option.id} value={option.id}> + {option.name} + </SelectItem> + )) + ) : ( + <div className="px-2 py-1.5 text-sm text-muted-foreground"> + 사용 가능한 옵션이 없습니다. + </div> + )} + </SelectContent> + </Select> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Description</FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} placeholder="예: PROJECT NO" /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="delimiter" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Delimiter</FormLabel> + <div className="col-span-3"> + <FormControl> + <Input {...field} placeholder="예: -, _, /" maxLength={10} /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem className="grid grid-cols-4 items-center gap-4 space-y-0"> + <FormLabel className="text-right">Remark</FormLabel> + <div className="col-span-3"> + <FormControl> + <Textarea {...field} placeholder="비고 사항" rows={3} /> + </FormControl> + <FormMessage className="col-start-2 col-span-3" /> + </div> + </FormItem> + )} + /> </div> - </div> - <DialogFooter> - <Button - type="button" - variant="outline" - onClick={() => setIsAddDialogOpen(false)} - disabled={isLoading} - > - 취소 - </Button> - <Button - type="submit" - disabled={isLoading} - > - {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - {isLoading ? "추가 중..." : "추가"} - </Button> - </DialogFooter> - </form> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setIsAddDialogOpen(false)} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "추가 중..." : "추가"} + </Button> + </DialogFooter> + </form> + </Form> </DialogContent> </Dialog> </div> ) -}
\ No newline at end of file +}
\ No newline at end of file diff --git a/lib/docu-list-rule/types.ts b/lib/docu-list-rule/types.ts index ef3f90d3..2baa72a5 100644 --- a/lib/docu-list-rule/types.ts +++ b/lib/docu-list-rule/types.ts @@ -6,6 +6,7 @@ export interface NumberTypeConfig { codeGroupId: number | null sdq: number description: string | null + delimiter:string | null remark: string | null isActive: boolean | null createdAt: Date diff --git a/lib/email-template/editor/template-content-editor.tsx b/lib/email-template/editor/template-content-editor.tsx index 08de53d2..e6091d0f 100644 --- a/lib/email-template/editor/template-content-editor.tsx +++ b/lib/email-template/editor/template-content-editor.tsx @@ -48,12 +48,6 @@ export function TemplateContentEditor({ template, onUpdate }: TemplateContentEdi getEditor: () => any }>(null) - React.useEffect(() => { - if (!session?.user?.id) { - toast.error("로그인이 필요합니다"); - } - }, [session]); - // 자동 미리보기 (디바운스) - 시간 늘림 React.useEffect(() => { if (!autoPreview) return diff --git a/lib/email-template/table/create-template-sheet.tsx b/lib/email-template/table/create-template-sheet.tsx index 199e20ab..1997cae8 100644 --- a/lib/email-template/table/create-template-sheet.tsx +++ b/lib/email-template/table/create-template-sheet.tsx @@ -65,12 +65,6 @@ export function CreateTemplateSheet({ ...props }: CreateTemplateSheetProps) { const router = useRouter() const { data: session } = useSession(); - // 또는 더 안전하게 - if (!session?.user?.id) { - toast.error("로그인이 필요합니다") - return - } - const form = useForm<CreateTemplateSchema>({ resolver: zodResolver(createTemplateSchema), defaultValues: { @@ -82,8 +76,8 @@ export function CreateTemplateSheet({ ...props }: CreateTemplateSheetProps) { }) // 이름 입력 시 자동으로 slug 생성 - const watchedName = form.watch("name") React.useEffect(() => { + const watchedName = form.watch("name") if (watchedName && !form.formState.dirtyFields.slug) { const autoSlug = watchedName .toLowerCase() @@ -95,7 +89,7 @@ export function CreateTemplateSheet({ ...props }: CreateTemplateSheetProps) { form.setValue("slug", autoSlug, { shouldValidate: false }) } - }, [watchedName, form]) + }, [form]) // 기본 템플릿 내용 생성 const getDefaultContent = (category: string, name: string) => { diff --git a/lib/email-template/table/update-template-sheet.tsx b/lib/email-template/table/update-template-sheet.tsx index d3df93f0..6a8c9a4a 100644 --- a/lib/email-template/table/update-template-sheet.tsx +++ b/lib/email-template/table/update-template-sheet.tsx @@ -58,12 +58,6 @@ export function UpdateTemplateSheet({ template, ...props }: UpdateTemplateSheetP const [isUpdatePending, startUpdateTransition] = React.useTransition() const { data: session } = useSession(); - // 또는 더 안전하게 - if (!session?.user?.id) { - toast.error("로그인이 필요합니다") - return - } - const form = useForm<UpdateTemplateSchema>({ resolver: zodResolver(updateTemplateSchema), defaultValues: { diff --git a/lib/permissions/permission-assignment-actions.ts b/lib/permissions/permission-assignment-actions.ts new file mode 100644 index 00000000..75181c40 --- /dev/null +++ b/lib/permissions/permission-assignment-actions.ts @@ -0,0 +1,83 @@ +// app/actions/permission-assignment-actions.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and ,sql} from "drizzle-orm"; +import { + permissions, + roles, + rolePermissions, + users, + userPermissions, + userRoles +} from "@/db/schema"; + +// 권한별 할당 정보 조회 +export async function getPermissionAssignments(permissionId?: number) { + if (!permissionId) { + // 모든 권한 목록 + const allPermissions = await db.select().from(permissions) + .where(eq(permissions.isActive, true)) + .orderBy(permissions.resource, permissions.name); + + return { permissions: allPermissions, roles: [], users: [] }; + } + + // 특정 권한의 할당 정보 + const assignedRoles = await db + .select({ + id: roles.id, + name: roles.name, + domain: roles.domain, + userCount: sql<number>`count(distinct ${userRoles.userId})`.mapWith(Number), + }) + .from(rolePermissions) + .innerJoin(roles, eq(roles.id, rolePermissions.roleId)) + .leftJoin(userRoles, eq(userRoles.roleId, roles.id)) + .where(eq(rolePermissions.permissionId, permissionId)) + .groupBy(roles.id); + + const assignedUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + isGrant: userPermissions.isGrant, + reason: userPermissions.reason, + }) + .from(userPermissions) + .innerJoin(users, eq(users.id, userPermissions.userId)) + .where(eq(userPermissions.permissionId, permissionId)); + + return { + permissions: [], + roles: assignedRoles, + users: assignedUsers, + }; +} + +// 역할에서 권한 제거 +export async function removePermissionFromRole(permissionId: number, roleId: number) { + await db.delete(rolePermissions) + .where( + and( + eq(rolePermissions.permissionId, permissionId), + eq(rolePermissions.roleId, roleId) + ) + ); +} + +// 사용자에서 권한 제거 +export async function removePermissionFromUser(permissionId: number, userId: number) { + await db.update(userPermissions) + .set({ isActive: false }) + .where( + and( + eq(userPermissions.permissionId, permissionId), + eq(userPermissions.userId, userId) + ) + ); +}
\ No newline at end of file diff --git a/lib/permissions/permission-group-actions.ts b/lib/permissions/permission-group-actions.ts new file mode 100644 index 00000000..51e3c2c0 --- /dev/null +++ b/lib/permissions/permission-group-actions.ts @@ -0,0 +1,270 @@ +// app/actions/permission-group-actions.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; +import { + permissionGroups, + permissionGroupMembers, + permissions, + rolePermissions, + userPermissions, + roles, + users +} from "@/db/schema"; +import { checkUserPermission } from "./service"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +// 권한 그룹 목록 조회 +export async function getPermissionGroups() { + const groups = await db + .select({ + id: permissionGroups.id, + groupKey: permissionGroups.groupKey, + name: permissionGroups.name, + description: permissionGroups.description, + domain: permissionGroups.domain, + isActive: permissionGroups.isActive, + createdAt: permissionGroups.createdAt, + updatedAt: permissionGroups.updatedAt, + permissionCount: sql<number>`count(distinct ${permissionGroupMembers.permissionId})`.mapWith(Number), + }) + .from(permissionGroups) + .leftJoin(permissionGroupMembers, eq(permissionGroupMembers.groupId, permissionGroups.id)) + .groupBy(permissionGroups.id) + .orderBy(permissionGroups.name); + + // 각 그룹의 역할 및 사용자 수 계산 + const groupsWithCounts = await Promise.all( + groups.map(async (group) => { + const roleCount = await db + .selectDistinct({ roleId: rolePermissions.roleId }) + .from(rolePermissions) + .where(eq(rolePermissions.permissionGroupId, group.id)); + + const userCount = await db + .selectDistinct({ userId: userPermissions.userId }) + .from(userPermissions) + .where(eq(userPermissions.permissionGroupId, group.id)); + + return { + ...group, + roleCount: roleCount.length, + userCount: userCount.length, + }; + }) + ); + + return groupsWithCounts; +} + +// 권한 그룹 생성 +export async function createPermissionGroup(data: { + groupKey: string; + name: string; + description?: string; + domain?: string; + isActive: boolean; +}) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + // 중복 체크 + const existing = await db.select() + .from(permissionGroups) + .where(eq(permissionGroups.groupKey, data.groupKey)) + .limit(1); + + if (existing.length > 0) { + throw new Error("이미 존재하는 그룹 키입니다."); + } + + const [created] = await db.insert(permissionGroups).values(data).returning(); + return created; +} + +// 권한 그룹 수정 +export async function updatePermissionGroup(id: number, data: any) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + const [updated] = await db.update(permissionGroups) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(permissionGroups.id, id)) + .returning(); + + return updated; +} + +// 권한 그룹 삭제 +export async function deletePermissionGroup(id: number) { + const currentUser = await getCurrentUser(); + if (!currentUser) throw new Error("Unauthorized"); + + if (!await checkUserPermission(currentUser.id, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + await db.transaction(async (tx) => { + // 그룹 멤버 삭제 + await tx.delete(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, id)); + + // 그룹 삭제 + await tx.delete(permissionGroups) + .where(eq(permissionGroups.id, id)); + }); +} + +// 그룹의 권한 조회 +export async function getGroupPermissions(groupId: number) { + const groupPermissions = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + resource: permissions.resource, + action: permissions.action, + permissionType: permissions.permissionType, + scope: permissions.scope, + }) + .from(permissionGroupMembers) + .innerJoin(permissions, eq(permissions.id, permissionGroupMembers.permissionId)) + .where(eq(permissionGroupMembers.groupId, groupId)); + + const allPermissions = await db.select().from(permissions) + .where(eq(permissions.isActive, true)); + + return { + permissions: groupPermissions, + availablePermissions: allPermissions, + }; +} + +// 그룹 권한 업데이트 +export async function updateGroupPermissions(groupId: number, permissionIds: number[]) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + await db.transaction(async (tx) => { + // 기존 권한 삭제 + await tx.delete(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + // 새 권한 추가 + if (permissionIds.length > 0) { + await tx.insert(permissionGroupMembers).values( + permissionIds.map(permissionId => ({ + groupId, + permissionId, + })) + ); + } + }); +} + +// 권한 그룹 복제 +export async function clonePermissionGroup(groupId: number) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + // 원본 그룹 조회 + const [originalGroup] = await db.select() + .from(permissionGroups) + .where(eq(permissionGroups.id, groupId)); + + if (!originalGroup) { + throw new Error("그룹을 찾을 수 없습니다."); + } + + // 원본 그룹의 권한 조회 + const originalPermissions = await db.select() + .from(permissionGroupMembers) + .where(eq(permissionGroupMembers.groupId, groupId)); + + // 새 그룹 생성 + const timestamp = Date.now(); + const [newGroup] = await db.insert(permissionGroups).values({ + groupKey: `${originalGroup.groupKey}_copy_${timestamp}`, + name: `${originalGroup.name} (복사본)`, + description: originalGroup.description, + domain: originalGroup.domain, + isActive: originalGroup.isActive, + }).returning(); + + // 권한 복사 + if (originalPermissions.length > 0) { + await db.insert(permissionGroupMembers).values( + originalPermissions.map(p => ({ + groupId: newGroup.id, + permissionId: p.permissionId, + })) + ); + } + + return newGroup; +} + +// 그룹 할당 정보 조회 +export async function getGroupAssignments(groupId: number) { + const assignedRoles = await db + .select({ + id: roles.id, + name: roles.name, + domain: roles.domain, + }) + .from(rolePermissions) + .innerJoin(roles, eq(roles.id, rolePermissions.roleId)) + .where(eq(rolePermissions.permissionGroupId, groupId)); + + const assignedUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + }) + .from(userPermissions) + .innerJoin(users, eq(users.id, userPermissions.userId)) + .where(eq(userPermissions.permissionGroupId, groupId)); + + return { + roles: assignedRoles, + users: assignedUsers, + }; +}
\ No newline at end of file diff --git a/lib/permissions/permission-settings-actions.ts b/lib/permissions/permission-settings-actions.ts new file mode 100644 index 00000000..5d04a1d3 --- /dev/null +++ b/lib/permissions/permission-settings-actions.ts @@ -0,0 +1,229 @@ +// app/actions/permission-settings-actions.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and, inArray, sql } from "drizzle-orm"; +import { + permissions, + menuAssignments, + menuRequiredPermissions +} from "@/db/schema"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { checkUserPermission } from "./service"; + +// 모든 권한 조회 +export async function getAllPermissions() { + return await db.select().from(permissions).orderBy(permissions.resource, permissions.action); +} + +// 권한 카테고리 조회 +export async function getPermissionCategories() { + const result = await db + .select({ + resource: permissions.resource, + count: sql<number>`count(*)`.mapWith(Number), + }) + .from(permissions) + .groupBy(permissions.resource) + .orderBy(permissions.resource); + + return result; +} + +// 권한 생성 +export async function createPermission(data: { + permissionKey: string; + name: string; + description?: string; + permissionType: string; + resource: string; + action: string; + scope: string; + menuPath?: string; + uiElement?: string; + isActive: boolean; +}) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + // 중복 체크 + const existing = await db.select() + .from(permissions) + .where(eq(permissions.permissionKey, data.permissionKey)) + .limit(1); + + if (existing.length > 0) { + throw new Error("이미 존재하는 권한 키입니다."); + } + + const [created] = await db.insert(permissions).values({ + ...data, + isSystem: false, + }).returning(); + + return created; +} + +// 권한 수정 +export async function updatePermission(id: number, data: any) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + const [updated] = await db.update(permissions) + .set({ + ...data, + updatedAt: new Date(), + }) + .where(eq(permissions.id, id)) + .returning(); + + return updated; +} + +// 권한 삭제 +export async function deletePermission(id: number) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + await db.delete(permissions).where(eq(permissions.id, id)); +} + +// 메뉴 권한 분석 +export async function analyzeMenuPermissions() { + const menus = await db.select().from(menuAssignments); + + const analysis = await Promise.all( + menus.map(async (menu) => { + // 기존 권한 조회 + const existing = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + }) + .from(menuRequiredPermissions) + .innerJoin(permissions, eq(permissions.id, menuRequiredPermissions.permissionId)) + .where(eq(menuRequiredPermissions.menuPath, menu.menuPath)); + + // 제안할 권한 생성 + const suggestedPermissions = []; + const resourceName = menu.menuPath.split('/').pop() || 'unknown'; + + // 기본 메뉴 접근 권한 + suggestedPermissions.push({ + permissionKey: `${resourceName}.menu_access`, + name: `${menu.menuTitle} 접근`, + permissionType: "menu_access", + action: "access", + scope: "assigned", + }); + + // CRUD 권한 제안 + const actions = [ + { action: "view", name: "조회", type: "data_read" }, + { action: "create", name: "생성", type: "data_write" }, + { action: "update", name: "수정", type: "data_write" }, + { action: "delete", name: "삭제", type: "data_delete" }, + ]; + + actions.forEach(({ action, name, type }) => { + suggestedPermissions.push({ + permissionKey: `${resourceName}.${action}`, + name: `${menu.menuTitle} ${name}`, + permissionType: type, + action, + scope: "assigned", + }); + }); + + return { + menuPath: menu.menuPath, + menuTitle: menu.menuTitle, + domain: menu.domain, + existingPermissions: existing, + suggestedPermissions: suggestedPermissions.filter( + sp => !existing.some(ep => ep.permissionKey === sp.permissionKey) + ), + }; + }) + ); + + return analysis; +} + +// 메뉴 기반 권한 생성 +export async function generateMenuPermissions( + permissionsToCreate: Array<{ + permissionKey: string; + name: string; + permissionType: string; + action: string; + scope: string; + menuPath: string; + }> +) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) + + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + let created = 0; + let skipped = 0; + + await db.transaction(async (tx) => { + for (const perm of permissionsToCreate) { + // 중복 체크 + const existing = await tx.select() + .from(permissions) + .where(eq(permissions.permissionKey, perm.permissionKey)) + .limit(1); + + if (existing.length === 0) { + const resource = perm.menuPath.split('/').pop() || 'unknown'; + + await tx.insert(permissions).values({ + permissionKey: perm.permissionKey, + name: perm.name, + permissionType: perm.permissionType, + resource, + action: perm.action, + scope: perm.scope, + menuPath: perm.menuPath, + isSystem: false, + isActive: true, + }); + created++; + } else { + skipped++; + } + } + }); + + return { created, skipped }; +}
\ No newline at end of file diff --git a/lib/permissions/service.ts b/lib/permissions/service.ts new file mode 100644 index 00000000..3ef1ff04 --- /dev/null +++ b/lib/permissions/service.ts @@ -0,0 +1,434 @@ +// lib/permission/servicee.ts + +"use server"; + +import db from "@/db/db"; +import { eq, and, inArray, or, ilike } from "drizzle-orm"; +import { + permissions, + rolePermissions, + userPermissions, + permissionAuditLogs, + userRoles, + menuAssignments, + menuRequiredPermissions, + users, + vendors, + roles, +} from "@/db/schema"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + +// 역할에 권한 할당 +export async function assignPermissionsToRole( + roleId: number, + permissionIds: number[] +) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + const currentUserId = Number(session.user.id) + + // 권한 체크 + if (!await checkUserPermission(currentUserId, "admin.permissions.manage")) { + throw new Error("권한 관리 권한이 없습니다."); + } + + await db.transaction(async (tx) => { + // 기존 권한 삭제 + await tx.delete(rolePermissions) + .where(eq(rolePermissions.roleId, roleId)); + + // 새 권한 추가 + if (permissionIds.length > 0) { + await tx.insert(rolePermissions).values( + permissionIds.map(permissionId => ({ + roleId, + permissionId, + grantedBy: currentUserId, + })) + ); + + // 감사 로그 + await tx.insert(permissionAuditLogs).values( + permissionIds.map(permissionId => ({ + targetType: "role", + targetId: roleId, + permissionId, + action: "grant", + performedBy: currentUserId, + reason: "역할 권한 일괄 업데이트", + })) + ); + } + }); + + return { success: true }; +} + + +// 역할의 권한 목록 조회 +export async function getRolePermissions(roleId: number) { + const allPermissions = await db.select().from(permissions) + .where(eq(permissions.isActive, true)); + + const rolePerms = await db.select({ + permissionId: rolePermissions.permissionId, + }) + .from(rolePermissions) + .where(eq(rolePermissions.roleId, roleId)); + + return { + permissions: allPermissions, + assignedPermissionIds: rolePerms.map(rp => rp.permissionId), + }; +} + +// 권한 체크 함수 +export async function checkUserPermission( + userId: number, + permissionKey: string +): Promise<boolean> { + // 역할 기반 권한 + const roleBasedPerms = await db + .selectDistinct({ permissionKey: permissions.permissionKey }) + .from(userRoles) + .innerJoin(rolePermissions, eq(rolePermissions.roleId, userRoles.roleId)) + .innerJoin(permissions, eq(permissions.id, rolePermissions.permissionId)) + .where( + and( + eq(userRoles.userId, userId), + eq(permissions.permissionKey, permissionKey), + eq(permissions.isActive, true), + eq(rolePermissions.isActive, true) + ) + ); + + if (roleBasedPerms.length > 0) return true; + + // 사용자 직접 권한 + const directPerms = await db + .selectDistinct({ permissionKey: permissions.permissionKey }) + .from(userPermissions) + .innerJoin(permissions, eq(permissions.id, userPermissions.permissionId)) + .where( + and( + eq(userPermissions.userId, userId), + eq(permissions.permissionKey, permissionKey), + eq(permissions.isActive, true), + eq(userPermissions.isActive, true), + eq(userPermissions.isGrant, true) // 부여된 권한만 + ) + ); + + return directPerms.length > 0; +} + +// 메뉴 접근 권한 체크 +export async function checkMenuAccess( + userId: number, + menuPath: string +): Promise<boolean> { + // 메뉴 담당자인 경우 자동 허용 + const isManager = await db + .selectDistinct({ id: menuAssignments.id }) + .from(menuAssignments) + .where( + and( + eq(menuAssignments.menuPath, menuPath), + or( + eq(menuAssignments.manager1Id, userId), + eq(menuAssignments.manager2Id, userId) + ) + ) + ); + + if (isManager.length > 0) return true; + + // 메뉴 필수 권한 체크 + const requiredPerms = await db + .select({ permissionKey: permissions.permissionKey }) + .from(menuRequiredPermissions) + .innerJoin(permissions, eq(permissions.id, menuRequiredPermissions.permissionId)) + .where( + and( + eq(menuRequiredPermissions.menuPath, menuPath), + eq(menuRequiredPermissions.isRequired, true) + ) + ); + + if (requiredPerms.length === 0) return true; // 필수 권한이 없으면 모두 접근 가능 + + // 사용자가 필수 권한을 모두 가지고 있는지 확인 + for (const perm of requiredPerms) { + if (!await checkUserPermission(userId, perm.permissionKey)) { + return false; + } + } + + return true; +} + + +export async function searchUsers(query: string) { + const usersData = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + domain: users.domain, + companyName: vendors.vendorName, + }) + .from(users) + .leftJoin(vendors, eq(vendors.id, users.companyId)) + .where( + or( + ilike(users.name, `%${query}%`), + ilike(users.email, `%${query}%`) + ) + ) + .limit(20); + + // 각 사용자의 역할 조회 + const usersWithRoles = await Promise.all( + usersData.map(async (user) => { + const userRolesData = await db + .select({ + id: roles.id, + name: roles.name, + }) + .from(userRoles) + .innerJoin(roles, eq(roles.id, userRoles.roleId)) + .where(eq(userRoles.userId, user.id)); + + return { + ...user, + roles: userRolesData, + }; + }) + ); + + return usersWithRoles; +} + +export async function getUserPermissionDetails(userId: number) { + // 역할 기반 권한 + const rolePermissionsData = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + permissionType: permissions.permissionType, + resource: permissions.resource, + action: permissions.action, + scope: permissions.scope, + menuPath: permissions.menuPath, + roleName: roles.name, + }) + .from(userRoles) + .innerJoin(roles, eq(roles.id, userRoles.roleId)) + .innerJoin(rolePermissions, eq(rolePermissions.roleId, roles.id)) + .innerJoin(permissions, eq(permissions.id, rolePermissions.permissionId)) + .where(eq(userRoles.userId, userId)); + + // 직접 부여된 권한 + const directPermissions = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + permissionType: permissions.permissionType, + resource: permissions.resource, + action: permissions.action, + scope: permissions.scope, + menuPath: permissions.menuPath, + isGrant: userPermissions.isGrant, + grantedBy: users.name, + grantedAt: userPermissions.grantedAt, + expiresAt: userPermissions.expiresAt, + reason: userPermissions.reason, + }) + .from(userPermissions) + .innerJoin(permissions, eq(permissions.id, userPermissions.permissionId)) + .leftJoin(users, eq(users.id, userPermissions.grantedBy)) + .where(eq(userPermissions.userId, userId)); + + // 모든 권한 목록 + const allPermissions = await db.select().from(permissions); + + return { + permissions: [ + ...rolePermissionsData.map(p => ({ ...p, source: "role" as const })), + ...directPermissions.map(p => ({ ...p, source: "direct" as const })), + ], + availablePermissions: allPermissions, + }; +} + +export async function grantPermissionToUser(params: { + userId: number; + permissionIds: number[]; + isGrant: boolean; + reason?: string; + expiresAt?: Date; +}) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + const currentUserId = Number(session.user.id) + + await db.transaction(async (tx) => { + for (const permissionId of params.permissionIds) { + await tx.insert(userPermissions).values({ + userId: params.userId, + permissionId, + isGrant: params.isGrant, + grantedBy: Number(session.user.id), + reason: params.reason, + expiresAt: params.expiresAt, + }).onConflictDoUpdate({ + target: [userPermissions.userId, userPermissions.permissionId], + set: { + isGrant: params.isGrant, + grantedBy: Number(session.user.id), + grantedAt: new Date(), + reason: params.reason, + expiresAt: params.expiresAt, + isActive: true, + } + }); + + // 감사 로그 + await tx.insert(permissionAuditLogs).values({ + targetType: "user", + targetId: params.userId, + permissionId, + action: params.isGrant ? "grant" : "restrict", + performedBy: currentUserId, + reason: params.reason, + }); + } + }); +} + +export async function revokePermissionFromUser(userId: number, permissionId: number) { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + throw new Error("인증이 필요합니다.") + } + + await db.transaction(async (tx) => { + await tx.update(userPermissions) + .set({ isActive: false }) + .where( + and( + eq(userPermissions.userId, userId), + eq(userPermissions.permissionId, permissionId) + ) + ); + + // 감사 로그 + await tx.insert(permissionAuditLogs).values({ + targetType: "user", + targetId: userId, + permissionId, + action: "revoke", + performedBy: Number(session.user.id), + }); + }); +} + + +export async function getMenuPermissions(domain: string = "all") { + const menus = await db + .select({ + menuPath: menuAssignments.menuPath, + menuTitle: menuAssignments.menuTitle, + menuDescription: menuAssignments.menuDescription, + sectionTitle: menuAssignments.sectionTitle, + menuGroup: menuAssignments.menuGroup, + domain: menuAssignments.domain, + isActive: menuAssignments.isActive, + manager1Id: menuAssignments.manager1Id, + manager2Id: menuAssignments.manager2Id, + }) + .from(menuAssignments) + .where(domain === "all" ? undefined : eq(menuAssignments.domain, domain)); + + // 각 메뉴의 권한과 담당자 정보 조회 + const menusWithDetails = await Promise.all( + menus.map(async (menu) => { + // 필수 권한 조회 + const requiredPerms = await db + .select({ + id: permissions.id, + permissionKey: permissions.permissionKey, + name: permissions.name, + description: permissions.description, + isRequired: menuRequiredPermissions.isRequired, + }) + .from(menuRequiredPermissions) + .innerJoin(permissions, eq(permissions.id, menuRequiredPermissions.permissionId)) + .where(eq(menuRequiredPermissions.menuPath, menu.menuPath)); + + // 담당자 정보 조회 + const [manager1, manager2] = await Promise.all([ + menu.manager1Id ? db.select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + }).from(users).where(eq(users.id, menu.manager1Id)).then(r => r[0]) : null, + menu.manager2Id ? db.select({ + id: users.id, + name: users.name, + email: users.email, + imageUrl: users.imageUrl, + }).from(users).where(eq(users.id, menu.manager2Id)).then(r => r[0]) : null, + ]); + + return { + ...menu, + requiredPermissions: requiredPerms, + manager1, + manager2, + }; + }) + ); + + // 사용 가능한 모든 권한 목록 + const availablePermissions = await db.select().from(permissions); + + return { + menus: menusWithDetails, + availablePermissions, + }; +} + +export async function updateMenuPermissions( + menuPath: string, + permissions: Array<{ id: number; isRequired: boolean }> +) { + await db.transaction(async (tx) => { + // 기존 권한 삭제 + await tx.delete(menuRequiredPermissions) + .where(eq(menuRequiredPermissions.menuPath, menuPath)); + + // 새 권한 추가 + if (permissions.length > 0) { + await tx.insert(menuRequiredPermissions).values( + permissions.map(p => ({ + menuPath, + permissionId: p.id, + isRequired: p.isRequired, + })) + ); + } + }); +}
\ No newline at end of file diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index be8e13e6..f2894577 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -39,21 +39,23 @@ export async function getRfqs(input: GetRfqsSchema) { switch (input.rfqCategory) { case "general": // 일반견적: rfqType이 있는 경우 - typeFilter = and( - isNotNull(rfqsLastView.rfqType), - ne(rfqsLastView.rfqType, '') - ); + // typeFilter = and( + // isNotNull(rfqsLastView.rfqType), + // ne(rfqsLastView.rfqType, '') + // ); + // 일반견적: rfqCode가 F로 시작하는 경우 + typeFilter = + like(rfqsLastView.rfqCode,'F%'); break; case "itb": // ITB: projectCompany가 있는 경우 typeFilter = - like(rfqsLastView.rfqCode,'I%') - - ; + like(rfqsLastView.rfqCode,'I%'); break; case "rfq": // RFQ: prNumber가 있는 경우 - typeFilter = like(rfqsLastView.rfqCode,'R%'); + typeFilter = + like(rfqsLastView.rfqCode,'R%'); break; } } @@ -244,7 +246,7 @@ export async function getRfqAllAttachments(rfqId: number) { } } } -// 사용자 목록 조회 (필터용) +// 사용자 목록 조회 (필터용), 견적담당자, 구매담당자 export async function getPUsersForFilter() { try { diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index f7515787..023c9f2a 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -76,7 +76,7 @@ const createGeneralRfqSchema = z.object({ }), picUserId: z.number().min(1, "견적담당자를 선택해주세요"), remark: z.string().optional(), - items: z.array(itemSchema).min(1, "최소 하나의 아이템을 추가해주세요"), + items: z.array(itemSchema).min(1, "최소 하나의 자재를 추가해주세요"), }) type CreateGeneralRfqFormValues = z.infer<typeof createGeneralRfqSchema> @@ -386,7 +386,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp {field.value ? ( format(field.value, "yyyy-MM-dd") ) : ( - <span>제출마감일을 선택하세요 (미선택 시 생성일 +7일)</span> + <span>제출마감일을 선택하세요</span> )} <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> </Button> @@ -562,7 +562,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp {/* 아이템 정보 섹션 - 컴팩트한 UI */} <div className="space-y-4"> <div className="flex items-center justify-between"> - <h3 className="text-lg font-semibold">아이템 정보</h3> + <h3 className="text-lg font-semibold">자재 정보</h3> <Button type="button" variant="outline" @@ -570,7 +570,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp onClick={handleAddItem} > <PlusCircle className="mr-2 h-4 w-4" /> - 아이템 추가 + 자재 추가 </Button> </div> @@ -579,7 +579,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50"> <div className="flex items-center justify-between mb-3"> <span className="text-sm font-medium text-gray-700"> - 아이템 #{index + 1} + 자재 #{index + 1} </span> {fields.length > 1 && ( <Button @@ -623,7 +623,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp render={({ field }) => ( <FormItem> <FormLabel className="text-xs"> - 자재명 <span className="text-red-500">*</span> + 자재그룹(자재그룹명) <span className="text-red-500">*</span> </FormLabel> <FormControl> <Input @@ -670,13 +670,29 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp <FormLabel className="text-xs"> 단위 <span className="text-red-500">*</span> </FormLabel> - <FormControl> - <Input - placeholder="EA" - className="h-8 text-sm" - {...field} - /> - </FormControl> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger className="h-8 text-sm"> + <SelectValue placeholder="단위 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="EA">EA (Each)</SelectItem> + <SelectItem value="KG">KG (Kilogram)</SelectItem> + <SelectItem value="M">M (Meter)</SelectItem> + <SelectItem value="L">L (Liter)</SelectItem> + <SelectItem value="PC">PC (Piece)</SelectItem> + <SelectItem value="BOX">BOX (Box)</SelectItem> + <SelectItem value="SET">SET (Set)</SelectItem> + <SelectItem value="LOT">LOT (Lot)</SelectItem> + <SelectItem value="PCS">PCS (Pieces)</SelectItem> + <SelectItem value="TON">TON (Ton)</SelectItem> + <SelectItem value="G">G (Gram)</SelectItem> + <SelectItem value="ML">ML (Milliliter)</SelectItem> + <SelectItem value="CM">CM (Centimeter)</SelectItem> + <SelectItem value="MM">MM (Millimeter)</SelectItem> + </SelectContent> + </Select> <FormMessage /> </FormItem> )} @@ -693,7 +709,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp <FormLabel className="text-xs">비고</FormLabel> <FormControl> <Input - placeholder="아이템별 비고사항" + placeholder="자재별 비고사항" className="h-8 text-sm" {...field} /> diff --git a/lib/rfq-last/table/rfq-table-columns.tsx b/lib/rfq-last/table/rfq-table-columns.tsx index fc7f4415..d0a9ee1e 100644 --- a/lib/rfq-last/table/rfq-table-columns.tsx +++ b/lib/rfq-last/table/rfq-table-columns.tsx @@ -140,7 +140,11 @@ export function getRfqColumns({ { accessorKey: "picUserName", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, - cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + cell: ({ row }) => { + const name = row.original.picUserName || row.original.picName || "-"; + const picCode = row.original.picCode || ""; + return name === "-" ? "-" : `${name}(${picCode})`; + }, size: 100, }, @@ -473,7 +477,11 @@ export function getRfqColumns({ { accessorKey: "picUserName", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, - cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + cell: ({ row }) => { + const name = row.original.picUserName || row.original.picName || "-"; + const picCode = row.original.picCode || ""; + return name === "-" ? "-" : `${name}(${picCode})`; + }, size: 100, }, @@ -1035,7 +1043,11 @@ export function getRfqColumns({ { accessorKey: "picUserName", header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구매담당자" />, - cell: ({ row }) => row.original.picUserName || row.original.picName || "-", + cell: ({ row }) => { + const name = row.original.picUserName || row.original.picName || "-"; + const picCode = row.original.picCode || ""; + return name === "-" ? "-" : `${name}(${picCode})`; + }, size: 100, }, diff --git a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx index 4a8960ff..26c3808a 100644 --- a/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx +++ b/lib/rfq-last/vendor-response/editor/quotation-items-table.tsx @@ -410,15 +410,15 @@ export default function QuotationItemsTable({ prItems }: QuotationItemsTableProp <Input type="number" min="0" - step="0.01" + step="1" {...register(`quotationItems.${index}.unitPrice`, { valueAsNumber: true })} onChange={(e) => { - const value = Math.max(0, parseFloat(e.target.value) || 0) + const value = Math.max(0, Math.floor(parseFloat(e.target.value) || 0)) setValue(`quotationItems.${index}.unitPrice`, value) calculateTotal(index) }} className="w-[120px]" - placeholder="0.00" + placeholder="0" /> <span className="text-xs text-muted-foreground"> {currency} diff --git a/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx index cfe24d73..2b3138d6 100644 --- a/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx +++ b/lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx @@ -380,7 +380,7 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment )} {/* 전체 다운로드 버튼 추가 */} - {attachments.length > 0 && !isLoading && ( + {/* {attachments.length > 0 && !isLoading && ( <Button onClick={handleDownloadAll} disabled={isDownloadingAll} @@ -399,7 +399,7 @@ export function RfqAttachmentsDialog({ isOpen, onClose, rfqData }: RfqAttachment </> )} </Button> - )} + )} */} </div> </DialogContent> </Dialog> diff --git a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx index 893fd9a3..ff3e27cc 100644 --- a/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx +++ b/lib/rfq-last/vendor/batch-update-conditions-dialog.tsx @@ -436,7 +436,9 @@ export function BatchUpdateConditionsDialog({ className="w-full justify-between" disabled={!fieldsToUpdate.currency} > + <span className="text-muted-foreground"> {field.value || "통화 선택"} + </span> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts index 38bf467c..651c8eda 100644 --- a/lib/rfqs/service.ts +++ b/lib/rfqs/service.ts @@ -1795,6 +1795,7 @@ export type Project = { id: number; projectCode: string; projectName: string; + type: string; } export async function getProjects(): Promise<Project[]> { @@ -1807,6 +1808,7 @@ export async function getProjects(): Promise<Project[]> { id: projects.id, projectCode: projects.code, // 테이블의 실제 컬럼명에 맞게 조정 projectName: projects.name, // 테이블의 실제 컬럼명에 맞게 조정 + type: projects.type, // 테이블의 실제 컬럼명에 맞게 조정 }) .from(projects) .orderBy(projects.code); diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts index e3c3f6bb..904d27ba 100644 --- a/lib/sedp/sync-form.ts +++ b/lib/sedp/sync-form.ts @@ -913,8 +913,8 @@ async function getContractItemsByItemCodes(itemCodes: string[], projectId: numbe export async function saveFormMappingsAndMetas( projectId: number, projectCode: string, - registers: Register[], // legacy SEDP Register list (supplemental) - newRegisters: newRegister[] // AdapterDataMapping list (primary) + registers: Register[], + newRegisters: newRegister[] ): Promise<number> { try { /* ------------------------------------------------------------------ */ @@ -929,14 +929,17 @@ export async function saveFormMappingsAndMetas( const registerMap = new Map(registers.map(r => [r.TYPE_ID, r])); const attributeMap = await getAttributes(projectCode); - const codeListMap = await getCodeLists(projectCode); + // getCodeLists 호출 제거 + // const codeListMap = await getCodeLists(projectCode); const uomMap = await getUOMs(projectCode); const defaultAttributes = await getDefaulTAttributes(); + // 성능 향상을 위한 코드 리스트 캐시 추가 (선택사항) + const codeListCache = new Map<string, CodeList | null>(); + /* ------------------------------------------------------------------ */ - /* 2. Contract‑item look‑up (SCOPES) - 수정된 부분 */ + /* 2. Contract‑item look‑up (SCOPES) */ /* ------------------------------------------------------------------ */ - // SCOPES 배열에서 모든 unique한 itemCode들을 추출 const uniqueItemCodes = [...new Set( newRegisters .filter(nr => nr.SCOPES && nr.SCOPES.length > 0) @@ -1002,9 +1005,26 @@ export async function saveFormMappingsAndMetas( ...(uomSymbol ? { uom: uomSymbol, uomId } : {}) }; + // 수정된 부분: getCodeListById 사용 if (!defaultAttributes.includes(attId) && (attribute.VAL_TYPE === "LIST" || attribute.VAL_TYPE === "DYNAMICLIST") && attribute.CL_ID) { - const cl = codeListMap.get(attribute.CL_ID); - if (cl?.VALUES?.length) col.options = [...new Set(cl.VALUES.filter(v => v.USE_YN).map(v => v.VALUE))]; + // 캐시 확인 + let cl = codeListCache.get(attribute.CL_ID); + + // 캐시에 없으면 API 호출 + if (!codeListCache.has(attribute.CL_ID)) { + try { + cl = await getCodeListById(projectCode, attribute.CL_ID); + codeListCache.set(attribute.CL_ID, cl); // 캐시에 저장 + } catch (error) { + console.warn(`코드 리스트 ${attribute.CL_ID} 가져오기 실패:`, error); + cl = null; + codeListCache.set(attribute.CL_ID, null); // 실패도 캐시에 저장 + } + } + + if (cl?.VALUES?.length) { + col.options = [...new Set(cl.VALUES.filter(v => v.USE_YN).map(v => v.VALUE))]; + } } columns.push(col); @@ -1025,7 +1045,6 @@ export async function saveFormMappingsAndMetas( if (!cls) { console.warn(`클래스 ${classId} 없음`); return; } const tp = tagTypeMap.get(cls.tagTypeCode); if (!tp) { console.warn(`태그 타입 ${cls.tagTypeCode} 없음`); return; } - // SCOPES 배열을 문자열로 변환하여 remark에 저장 const scopesRemark = newReg.SCOPES && newReg.SCOPES.length > 0 ? newReg.SCOPES.join(', ') : null; mappingsToSave.push({ projectId, @@ -1040,13 +1059,11 @@ export async function saveFormMappingsAndMetas( }); }); - /* ---------- 4‑d. contractItem ↔ form - 수정된 부분 -------------- */ + /* ---------- 4‑d. contractItem ↔ form -------------------------- */ if (newReg.SCOPES && newReg.SCOPES.length > 0) { - // SCOPES 배열의 각 itemCode에 대해 처리 for (const itemCode of newReg.SCOPES) { const contractItemIds = itemCodeToContractItemIds.get(itemCode); if (contractItemIds && contractItemIds.length > 0) { - // 모든 contractItemId에 대해 form 생성 contractItemIds.forEach(cId => { contractItemIdsWithForms.add(cId); formsToSave.push({ @@ -1096,7 +1113,6 @@ export async function saveFormMappingsAndMetas( } } - // 메인 동기화 함수 export async function syncTagFormMappings() { try { diff --git a/lib/tags/service.ts b/lib/tags/service.ts index cef20209..028cde42 100644 --- a/lib/tags/service.ts +++ b/lib/tags/service.ts @@ -393,69 +393,89 @@ export async function createTagInForm( formCode: string, packageCode: string ) { + // 1. 초기 검증 if (!selectedPackageId) { - return { error: "No selectedPackageId provided" } + console.error("[CREATE TAG] No selectedPackageId provided"); + return { + success: false, + error: "No selectedPackageId provided" + }; } - // Validate formData - const validated = createTagSchema.safeParse(formData) + // 2. FormData 검증 + const validated = createTagSchema.safeParse(formData); if (!validated.success) { - return { error: validated.error.flatten().formErrors.join(", ") } + const errorMsg = validated.error.flatten().formErrors.join(", "); + console.error("[CREATE TAG] Validation failed:", errorMsg); + return { + success: false, + error: errorMsg + }; } - // React 서버 액션에서 매 요청마다 실행 - unstable_noStore() + // 3. 캐시 무효화 설정 + unstable_noStore(); try { - // 하나의 트랜잭션에서 모든 작업 수행 + // 4. 트랜잭션 시작 return await db.transaction(async (tx) => { - // 1) 선택된 contractItem의 contractId 가져오기 + // 5. Contract Item 정보 조회 const contractItemResult = await tx .select({ contractId: contractItems.contractId, - projectId: contracts.projectId, // projectId 추가 - vendorId: contracts.vendorId // projectId 추가 + projectId: contracts.projectId, + vendorId: contracts.vendorId }) .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .where(eq(contractItems.id, selectedPackageId)) - .limit(1) + .limit(1); if (contractItemResult.length === 0) { - return { error: "Contract item not found" } + console.error("[CREATE TAG] Contract item not found"); + return { + success: false, + error: "Contract item not found" + }; } - const contractId = contractItemResult[0].contractId - const projectId = contractItemResult[0].projectId - const vendorId = contractItemResult[0].vendorId + const { contractId, projectId, vendorId } = contractItemResult[0]; - const vendor = await db.query.vendors.findFirst({ + // 6. Vendor 정보 조회 + const vendor = await tx.query.vendors.findFirst({ where: eq(vendors.id, vendorId) }); - + if (!vendor) { - return { error: "선택한 벤더를 찾을 수 없습니다." }; + console.error("[CREATE TAG] Vendor not found"); + return { + success: false, + error: "선택한 벤더를 찾을 수 없습니다." + }; } - // 2) 해당 계약 내에서 같은 tagNo를 가진 태그가 있는지 확인 + // 7. 중복 태그 확인 const duplicateCheck = await tx .select({ count: sql<number>`count(*)` }) .from(tags) .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) .where( and( - eq(contractItems.contractId, contractId), + eq(contracts.projectId, projectId), eq(tags.tagNo, validated.data.tagNo) ) - ) + ); if (duplicateCheck[0].count > 0) { + console.error(`[CREATE TAG] Duplicate tag number: ${validated.data.tagNo}`); return { + success: false, error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, - } + }; } - // 3) 먼저 기존 form 찾기 + // 8. Form 조회 let form = await tx.query.forms.findFirst({ where: and( eq(forms.formCode, formCode), @@ -463,191 +483,183 @@ export async function createTagInForm( ) }); - // 4) form이 없으면 formMappings를 통해 생성 + // 9. Form이 없으면 생성 if (!form) { - console.log(`[CREATE TAG IN FORM] Form ${formCode} not found, attempting to create...`); + console.log(`[CREATE TAG] Form ${formCode} not found, attempting to create...`); - // 태그 타입에 따른 폼 정보 가져오기 + // Form Mappings 조회 const allFormMappings = await getFormMappingsByTagType( validated.data.tagType, projectId, validated.data.class - ) - - - - - // ep가 "IMEP"인 것만 필터링 - const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || [] + ); - // 현재 formCode와 일치하는 매핑 찾기 + // IMEP 폼만 필터링 + const formMappings = allFormMappings?.filter(mapping => mapping.ep === "IMEP") || []; const targetFormMapping = formMappings.find(mapping => mapping.formCode === formCode); - if (targetFormMapping) { - console.log(`[CREATE TAG IN FORM] Found form mapping for ${formCode}, creating form...`); - - // form 생성 - const insertResult = await tx - .insert(forms) - .values({ - contractItemId: selectedPackageId, - formCode: targetFormMapping.formCode, - formName: targetFormMapping.formName, - im: true, - }) - .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) - - form = { - id: insertResult[0].id, - formCode: insertResult[0].formCode, - formName: insertResult[0].formName, - contractItemId: selectedPackageId, - im: true, - createdAt: new Date(), - updatedAt: new Date() - }; - - console.log(`[CREATE TAG IN FORM] Successfully created form:`, insertResult[0]); - } else { - console.log(`[CREATE TAG IN FORM] No IMEP form mapping found for formCode: ${formCode}`); - console.log(`[CREATE TAG IN FORM] Available IMEP mappings:`, formMappings.map(m => m.formCode)); + if (!targetFormMapping) { + console.error(`[CREATE TAG] No IMEP form mapping found for formCode: ${formCode}`); return { + success: false, error: `Form ${formCode} not found and no IMEP mapping available for tag type ${validated.data.tagType}` }; } - } else { - console.log(`[CREATE TAG IN FORM] Found existing form:`, form.id); + + // Form 생성 + const insertResult = await tx + .insert(forms) + .values({ + contractItemId: selectedPackageId, + formCode: targetFormMapping.formCode, + formName: targetFormMapping.formName, + im: true, + }) + .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }); + + form = { + id: insertResult[0].id, + formCode: insertResult[0].formCode, + formName: insertResult[0].formName, + contractItemId: selectedPackageId, + im: true, + createdAt: new Date(), + updatedAt: new Date() + }; - // 기존 form이 있지만 im이 false인 경우 true로 업데이트 + console.log(`[CREATE TAG] Successfully created form:`, insertResult[0]); + } else { + // 기존 form의 im 상태 업데이트 if (form.im !== true) { await tx .update(forms) .set({ im: true }) - .where(eq(forms.id, form.id)) + .where(eq(forms.id, form.id)); - console.log(`[CREATE TAG IN FORM] Form ${form.id} updated with im: true`) + console.log(`[CREATE TAG] Form ${form.id} updated with im: true`); } } - if (form?.id) { - // 🆕 16진수 24자리 태그 고유 식별자 생성 - const generatedTagIdx = generateTagIdx(); - console.log(`[CREATE TAG IN FORM] Generated tagIdx: ${generatedTagIdx}`); + // 10. Form이 있는 경우에만 진행 + if (!form?.id) { + console.error("[CREATE TAG] Failed to create or find form"); + return { + success: false, + error: "Failed to create or find form" + }; + } - // 5) 새 Tag 생성 (tagIdx 추가) - const [newTag] = await insertTag(tx, { - contractItemId: selectedPackageId, - formId: form.id, - tagIdx: generatedTagIdx, // 🆕 생성된 16진수 24자리 추가 - tagNo: validated.data.tagNo, - class: validated.data.class, - tagType: validated.data.tagType, - description: validated.data.description ?? null, - }) + // 11. Tag Index 생성 + const generatedTagIdx = generateTagIdx(); + console.log(`[CREATE TAG] Generated tagIdx: ${generatedTagIdx}`); - // 6) 기존 formEntry 가져오기 - const entry = await tx.query.formEntries.findFirst({ - where: and( - eq(formEntries.formCode, formCode), - eq(formEntries.contractItemId, selectedPackageId), - ) - }); + // 12. 새 Tag 생성 + const [newTag] = await insertTag(tx, { + contractItemId: selectedPackageId, + formId: form.id, + tagIdx: generatedTagIdx, + tagNo: validated.data.tagNo, + class: validated.data.class, + tagType: validated.data.tagType, + description: validated.data.description ?? null, + }); - if (entry && entry.id) { - // 7) 기존 데이터 가져오기 (배열인지 확인) - TAG_IDX 타입 추가 - let existingData: Array<{ - TAG_IDX?: string; // 🆕 TAG_IDX 필드 추가 - TAG_NO: string; - TAG_DESC?: string; - status?: string; - [key: string]: any; // 다른 필드들도 포함 - }> = []; - - if (Array.isArray(entry.data)) { - existingData = entry.data; - } + // 13. Tag Class 조회 + const tagClass = await tx.query.tagClasses.findFirst({ + where: and( + eq(tagClasses.projectId, projectId), + eq(tagClasses.label, validated.data.class) + ) + }); - console.log(`[CREATE TAG IN FORM] Existing data count: ${existingData.length}`); + if (!tagClass) { + console.warn("[CREATE TAG] Tag class not found, using default"); + } - const tagClass = await db.query.tagClasses.findFirst({ - where: and(eq(tagClasses.projectId, projectId),eq(tagClasses.label, validated.data.class)) - }); + // 14. FormEntry 처리 + const entry = await tx.query.formEntries.findFirst({ + where: and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, selectedPackageId), + ) + }); - // 8) 새로운 태그를 기존 데이터에 추가 (TAG_IDX 포함) - const newTagData = { - TAG_IDX: generatedTagIdx, // 🆕 같은 16진수 24자리 값 사용 - TAG_NO: validated.data.tagNo, - TAG_DESC: validated.data.description ?? null, - CLS_ID: tagClass.code, - VNDRCD: vendor.vendorCode, - VNDRNM_1: vendor.vendorName, - CM3003: packageCode, - ME5074: packageCode, + // 15. 새로운 태그 데이터 준비 + const newTagData = { + TAG_IDX: generatedTagIdx, + TAG_NO: validated.data.tagNo, + TAG_DESC: validated.data.description ?? null, + CLS_ID: tagClass?.code || validated.data.class, // tagClass가 없을 경우 대비 + VNDRCD: vendor.vendorCode, + VNDRNM_1: vendor.vendorName, + CM3003: packageCode, + ME5074: packageCode, + status: "New" // 수동으로 생성된 태그임을 표시 + }; - status: "New" // 수동으로 생성된 태그임을 표시 - }; + if (entry?.id) { + // 16. 기존 FormEntry 업데이트 + let existingData: Array<any> = []; + if (Array.isArray(entry.data)) { + existingData = entry.data; + } - const updatedData = [...existingData, newTagData]; + console.log(`[CREATE TAG] Existing data count: ${existingData.length}`); - console.log(`[CREATE TAG IN FORM] Updated data count: ${updatedData.length}`); - console.log(`[CREATE TAG IN FORM] Added tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}, status: 수동 생성`); + const updatedData = [...existingData, newTagData]; - // 9) formEntries 업데이트 - await tx - .update(formEntries) - .set({ - data: updatedData, - updatedAt: new Date() // 업데이트 시간도 갱신 - }) - .where(eq(formEntries.id, entry.id)); - } else { - // 10) formEntry가 없는 경우 새로 생성 (TAG_IDX 포함) - console.log(`[CREATE TAG IN FORM] No existing formEntry found, creating new one`); - - const newEntryData = [{ - TAG_IDX: generatedTagIdx, // 🆕 같은 16진수 24자리 값 사용 - TAG_NO: validated.data.tagNo, - TAG_DESC: validated.data.description ?? null, - status: "New" // 수동으로 생성된 태그임을 표시 - }]; + await tx + .update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, entry.id)); - await tx.insert(formEntries).values({ - formCode: formCode, - contractItemId: selectedPackageId, - data: newEntryData, - createdAt: new Date(), - updatedAt: new Date(), - }); - } + console.log(`[CREATE TAG] Updated formEntry with new tag`); + } else { + // 17. 새 FormEntry 생성 + console.log(`[CREATE TAG] Creating new formEntry`); + + await tx.insert(formEntries).values({ + formCode: formCode, + contractItemId: selectedPackageId, + data: [newTagData], + createdAt: new Date(), + updatedAt: new Date(), + }); + + console.log(`[CREATE TAG] Created new formEntry`); + } + + // 18. 캐시 무효화 + revalidateTag(`tags-${selectedPackageId}`); + revalidateTag(`forms-${selectedPackageId}`); + revalidateTag(`form-data-${formCode}-${selectedPackageId}`); + revalidateTag("tags"); - // 12) 성공 시 반환 + console.log(`[CREATE TAG] Successfully created tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}`); + + // 19. 성공 응답 return { success: true, data: { formId: form.id, tagNo: validated.data.tagNo, - tagIdx: generatedTagIdx, // 🆕 생성된 tagIdx도 반환 - formCreated: !form // form이 새로 생성되었는지 여부 + tagIdx: generatedTagIdx, + formCreated: !form } - } - - console.log(`[CREATE TAG IN FORM] Successfully created tag: ${validated.data.tagNo} with tagIdx: ${generatedTagIdx}`) - } else { - return { error: "Failed to create or find form" }; - } - - // 11) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) - revalidateTag(`tags-${selectedPackageId}`) - revalidateTag(`forms-${selectedPackageId}`) - revalidateTag(`form-data-${formCode}-${selectedPackageId}`) // 폼 데이터 캐시도 무효화 - revalidateTag("tags") - - - }) + }; + }); } catch (err: any) { - console.log("createTag in Form error:", err) - console.error("createTag in Form error:", err) - return { error: getErrorMessage(err) } + // 20. 에러 처리 + console.error("[CREATE TAG] Transaction error:", err); + const errorMessage = getErrorMessage(err); + + return { + success: false, + error: errorMessage + }; } } diff --git a/lib/vendor-document-list/plant/document-stage-actions.ts b/lib/vendor-document-list/plant/document-stage-actions.ts deleted file mode 100644 index e69de29b..00000000 --- a/lib/vendor-document-list/plant/document-stage-actions.ts +++ /dev/null diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 14035562..779d31e1 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -32,7 +32,7 @@ import { } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" import { StageDocumentsView } from "@/db/schema" -import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle} from "lucide-react" +import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle, FileText, FolderOpen} from "lucide-react" import { toast } from "sonner" import { getDocumentNumberTypes, @@ -60,11 +60,11 @@ import { DrawerTrigger, } from "@/components/ui/drawer" import { useRouter } from "next/navigation" -import { cn, formatDate } from "@/lib/utils" -import ExcelJS from 'exceljs' -import { Progress } from "@/components/ui/progress" import { Alert, AlertDescription } from "@/components/ui/alert" -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Controller, useForm, useWatch } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" const getStatusVariant = (status: string) => { switch (status) { @@ -88,17 +88,24 @@ const getStatusText = (status: string) => { } -// ============================================================================= -// 1. Add Document Dialog -// ============================================================================= +// Form validation schema +const documentFormSchema = z.object({ + documentClassId: z.string().min(1, "Document class is required"), + title: z.string().min(1, "Document title is required"), + shiFieldValues: z.record(z.string()).optional(), + cpyFieldValues: z.record(z.string()).optional(), + planDates: z.record(z.string()).optional(), +}) + +type DocumentFormValues = z.infer<typeof documentFormSchema> + interface AddDocumentDialogProps { open: boolean onOpenChange: (open: boolean) => void contractId: number - projectType: "ship" | "plant" + projectType?: string } - export function AddDocumentDialog({ open, onOpenChange, @@ -106,113 +113,115 @@ export function AddDocumentDialog({ projectType }: AddDocumentDialogProps) { const [isLoadingInitialData, setIsLoadingInitialData] = React.useState(false) - const [isSubmitting, setIsSubmitting] = React.useState(false) - const [documentNumberTypes, setDocumentNumberTypes] = React.useState<any[]>([]) const [documentClasses, setDocumentClasses] = React.useState<any[]>([]) - const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([]) - const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([]) - // SHI와 CPY 타입 체크 + // SHI related states const [shiType, setShiType] = React.useState<any>(null) + const [shiTypeConfigs, setShiTypeConfigs] = React.useState<any[]>([]) + const [shiComboBoxOptions, setShiComboBoxOptions] = React.useState<Record<number, any[]>>({}) + + // CPY related states const [cpyType, setCpyType] = React.useState<any>(null) - const [activeTab, setActiveTab] = React.useState<"SHI" | "CPY">("SHI") - const [dataLoaded, setDataLoaded] = React.useState(false) + const [cpyTypeConfigs, setCpyTypeConfigs] = React.useState<any[]>([]) + const [cpyComboBoxOptions, setCpyComboBoxOptions] = React.useState<Record<number, any[]>>({}) + + // Initialize react-hook-form + const form = useForm<DocumentFormValues>({ + resolver: zodResolver(documentFormSchema), + defaultValues: { + documentClassId: '', + title: '', + shiFieldValues: {}, + cpyFieldValues: {}, + planDates: {}, + }, + }) - console.log(dataLoaded,"dataLoaded") + // Watch form values for reactive updates + const documentClassId = useWatch({ + control: form.control, + name: 'documentClassId', + }) - const [formData, setFormData] = React.useState({ - documentNumberTypeId: "", - documentClassId: "", - title: "", - fieldValues: {} as Record<string, string>, - planDates: {} as Record<number, string> + const shiFieldValues = useWatch({ + control: form.control, + name: 'shiFieldValues', }) - // Load initial data -// Dialog가 닫힐 때 상태 초기화를 확실히 하기 -React.useEffect(() => { - if (!open) { - // Dialog가 닫힐 때만 초기화 - resetForm() - } else if (!dataLoaded) { - // Dialog가 열리고 데이터가 로드되지 않았을 때만 - loadInitialData() - } -}, [open]) + const cpyFieldValues = useWatch({ + control: form.control, + name: 'cpyFieldValues', + }) + // Load initial data when dialog opens + React.useEffect(() => { + if (open) { + loadInitialData() + } else { + // Reset form when dialog closes + form.reset() + setShiTypeConfigs([]) + setCpyTypeConfigs([]) + setShiComboBoxOptions({}) + setCpyComboBoxOptions({}) + setDocumentClassOptions([]) + } + }, [open]) + + // Load document class options when class changes + React.useEffect(() => { + if (documentClassId) { + loadDocumentClassOptions(documentClassId) + } + }, [documentClassId]) const loadInitialData = async () => { setIsLoadingInitialData(true) - let foundShiType = null; - let foundCpyType = null; - try { const [typesResult, classesResult] = await Promise.all([ getDocumentNumberTypes(contractId), getDocumentClasses(contractId) ]) - console.log(typesResult,"typesResult") - if (typesResult.success && typesResult.data) { - setDocumentNumberTypes(typesResult.data) - - // 로컬 변수에 먼저 저장 - foundShiType = typesResult.data.find((type: any) => - type.name?.toUpperCase().trim() === "SHI" + const foundShiType = typesResult.data.find((type: any) => + type.name?.toUpperCase().trim() === 'SHI' ) - foundCpyType = typesResult.data.find((type: any) => - type.name?.toUpperCase().trim() === "CPY" + const foundCpyType = typesResult.data.find((type: any) => + type.name?.toUpperCase().trim() === 'CPY' ) setShiType(foundShiType || null) setCpyType(foundCpyType || null) - - // 로컬 변수 사용 + + // Load configs for both types if (foundShiType) { - await handleTabChange("SHI", String(foundShiType.id)) - } else if (foundCpyType) { - setActiveTab("CPY") - await handleTabChange("CPY", String(foundCpyType.id)) + await loadShiTypeConfigs(foundShiType.id) + } + if (foundCpyType) { + await loadCpyTypeConfigs(foundCpyType.id) } } if (classesResult.success) { setDocumentClasses(classesResult.data) } - - setDataLoaded(true) } catch (error) { - console.error("Error loading data:", error) - toast.error("Error loading data.") + console.error('Error loading initial data:', error) + toast.error('Error loading data.') } finally { - // 로컬 변수를 체크 - if (!foundShiType && !foundCpyType) { - console.error("No types found after loading") - } setIsLoadingInitialData(false) } } - // 탭 변경 처리 - const handleTabChange = async (tab: "SHI" | "CPY", typeId?: string) => { - setActiveTab(tab) - - const documentNumberTypeId = typeId || (tab === "SHI" ? shiType?.id : cpyType?.id) - - if (documentNumberTypeId) { - setFormData(prev => ({ - ...prev, - documentNumberTypeId: String(documentNumberTypeId), - fieldValues: {} - })) - - const configsResult = await getDocumentNumberTypeConfigs(Number(documentNumberTypeId)) + const loadShiTypeConfigs = async (typeId: number) => { + try { + const configsResult = await getDocumentNumberTypeConfigs(typeId) if (configsResult.success) { - setSelectedTypeConfigs(configsResult.data) + setShiTypeConfigs(configsResult.data) - // Pre-load combobox options + // Pre-load combobox options for SHI const comboBoxPromises = configsResult.data .filter(config => config.codeGroup?.controlType === 'combobox') .map(async (config) => { @@ -230,62 +239,82 @@ React.useEffect(() => { newComboBoxOptions[result.codeGroupId] = result.options } }) - setComboBoxOptions(newComboBoxOptions) + setShiComboBoxOptions(newComboBoxOptions) } - } else { - setSelectedTypeConfigs([]) - setComboBoxOptions({}) + } catch (error) { + console.error('Error loading SHI type configs:', error) } } - // Handle field value change - const handleFieldValueChange = (fieldKey: string, value: string) => { - setFormData({ - ...formData, - fieldValues: { - ...formData.fieldValues, - [fieldKey]: value + const loadCpyTypeConfigs = async (typeId: number) => { + try { + const configsResult = await getDocumentNumberTypeConfigs(typeId) + if (configsResult.success) { + setCpyTypeConfigs(configsResult.data) + + // Pre-load combobox options for CPY + const comboBoxPromises = configsResult.data + .filter(config => config.codeGroup?.controlType === 'combobox') + .map(async (config) => { + const optionsResult = await getComboBoxOptions(config.codeGroupId!, contractId) + return { + codeGroupId: config.codeGroupId, + options: optionsResult.success ? optionsResult.data : [] + } + }) + + const comboBoxResults = await Promise.all(comboBoxPromises) + const newComboBoxOptions: Record<number, any[]> = {} + comboBoxResults.forEach(result => { + if (result.codeGroupId) { + newComboBoxOptions[result.codeGroupId] = result.options + } + }) + setCpyComboBoxOptions(newComboBoxOptions) } - }) + } catch (error) { + console.error('Error loading CPY type configs:', error) + } } - // Handle document class change - const handleDocumentClassChange = async (documentClassId: string) => { - setFormData({ - ...formData, - documentClassId, - planDates: {} - }) - - if (documentClassId) { - const optionsResult = await getDocumentClassOptions(Number(documentClassId)) + const loadDocumentClassOptions = async (classId: string) => { + try { + const optionsResult = await getDocumentClassOptions(Number(classId)) if (optionsResult.success) { setDocumentClassOptions(optionsResult.data) + // Reset plan dates for new class + form.setValue('planDates', {}) } - } else { - setDocumentClassOptions([]) + } catch (error) { + console.error('Error loading class options:', error) } } - // Handle plan date change - const handlePlanDateChange = (optionId: number, date: string) => { - setFormData({ - ...formData, - planDates: { - ...formData.planDates, - [optionId]: date + // Generate document number preview for SHI + const generateShiPreview = () => { + if (shiTypeConfigs.length === 0) return '' + + let preview = '' + shiTypeConfigs.forEach((config, index) => { + const fieldKey = `field_${config.sdq}` + const value = shiFieldValues?.[fieldKey] || '[value]' + + if (index > 0 && config.delimiter) { + preview += config.delimiter } + preview += value }) + return preview } - // Generate document number preview - const generatePreviewDocNumber = () => { - if (selectedTypeConfigs.length === 0) return "" + // Generate document number preview for CPY + const generateCpyPreview = () => { + if (cpyTypeConfigs.length === 0) return '' - let preview = "" - selectedTypeConfigs.forEach((config, index) => { + let preview = '' + cpyTypeConfigs.forEach((config, index) => { const fieldKey = `field_${config.sdq}` - const value = formData.fieldValues[fieldKey] || "[value]" + const value = cpyFieldValues?.[fieldKey] || '[value]' if (index > 0 && config.delimiter) { preview += config.delimiter @@ -295,228 +324,155 @@ React.useEffect(() => { return preview } - // Check if form is valid for submission - const isFormValid = () => { - if (!formData.documentNumberTypeId || !formData.documentClassId || !formData.title.trim()) { - return false - } + // Check if SHI fields are complete + const isShiComplete = () => { + if (!shiType || shiTypeConfigs.length === 0) return true // Skip if not configured - const requiredConfigs = selectedTypeConfigs.filter(config => config.required) + const requiredConfigs = shiTypeConfigs.filter(config => config.required) for (const config of requiredConfigs) { const fieldKey = `field_${config.sdq}` - const value = formData.fieldValues[fieldKey] + const value = shiFieldValues?.[fieldKey] if (!value || !value.trim()) { return false } } - const docNumber = generatePreviewDocNumber() - if (!docNumber || docNumber === "" || docNumber.includes("[value]")) { - return false + const preview = generateShiPreview() + return preview && preview !== '' && !preview.includes('[value]') + } + + // Check if CPY fields are complete + const isCpyComplete = () => { + if (!cpyType || cpyTypeConfigs.length === 0) return true // Skip if not configured + + const requiredConfigs = cpyTypeConfigs.filter(config => config.required) + for (const config of requiredConfigs) { + const fieldKey = `field_${config.sdq}` + const value = cpyFieldValues?.[fieldKey] + if (!value || !value.trim()) { + return false + } } - return true + const preview = generateCpyPreview() + return preview && preview !== '' && !preview.includes('[value]') } - const handleSubmit = async () => { - if (!isFormValid()) { - toast.error("Please fill in all required fields.") + const onSubmit = async (data: DocumentFormValues) => { + // Validate that at least one document number is configured and complete + if (shiType && !isShiComplete()) { + toast.error('Please fill in all required SHI document number fields.') return } - - const generatedDocNumber = generatePreviewDocNumber() - if (!generatedDocNumber) { - toast.error("Cannot generate document number.") + + if (cpyType && !isCpyComplete()) { + toast.error('Please fill in all required CPY project document number fields.') return } - setIsSubmitting(true) + const shiDocNumber = shiType ? generateShiPreview() : '' + const cpyDocNumber = cpyType ? generateCpyPreview() : '' + try { - // CPY 탭에서는 생성된 문서번호를 vendorDocNumber로 저장 const submitData = { contractId, - documentNumberTypeId: Number(formData.documentNumberTypeId), - documentClassId: Number(formData.documentClassId), - title: formData.title, - docNumber: activeTab === "SHI" ? generatedDocNumber : "", // SHI는 docNumber로 - vendorDocNumber: activeTab === "CPY" ? generatedDocNumber : "", // CPY는 vendorDocNumber로 - fieldValues: formData.fieldValues, - planDates: formData.planDates, + documentClassId: Number(data.documentClassId), + title: data.title, + docNumber: shiDocNumber, + vendorDocNumber: cpyDocNumber, + fieldValues: { + ...data.shiFieldValues, + ...data.cpyFieldValues + }, + planDates: data.planDates || {}, } const result = await createDocument(submitData) if (result.success) { - toast.success("Document added successfully.") + toast.success('Document added successfully.') onOpenChange(false) - resetForm() + form.reset() } else { - toast.error(result.error || "Error adding document.") + toast.error(result.error || 'Error adding document.') } } catch (error) { - toast.error("Error adding document.") - } finally { - setIsSubmitting(false) + console.error('Error submitting document:', error) + toast.error('Error adding document.') } } - const resetForm = () => { - setFormData({ - documentNumberTypeId: "", - documentClassId: "", - title: "", - fieldValues: {}, - planDates: {} - }) - setSelectedTypeConfigs([]) - setComboBoxOptions({}) - setDocumentClassOptions([]) - setActiveTab("SHI") - setDataLoaded(false) - } + // Check if we have at least one type available + const hasAvailableTypes = shiType || cpyType - // 공통 폼 컴포넌트 - const DocumentForm = () => ( - <div className="grid gap-4"> - {/* Dynamic Fields */} - {selectedTypeConfigs.length > 0 && ( - <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> - <Label className="text-sm font-medium text-blue-800 dark:text-blue-200 mb-3 block"> - Document Number Components - </Label> - <div className="grid gap-3"> - {selectedTypeConfigs.map((config) => ( - <div key={config.id} className="grid gap-2"> - <Label className="text-sm"> - {config.codeGroup?.description || config.description} - {config.required && <span className="text-red-500 ml-1">*</span>} - {config.remark && ( - <span className="text-xs text-gray-500 dark:text-gray-400 ml-2">({config.remark})</span> - )} - </Label> + // Render field component + const renderField = ( + config: any, + fieldType: 'SHI' | 'CPY', + comboBoxOptions: Record<number, any[]> + ) => { + const fieldKey = `field_${config.sdq}` + const fieldName = fieldType === 'SHI' + ? `shiFieldValues.${fieldKey}` + : `cpyFieldValues.${fieldKey}` - {config.codeGroup?.controlType === 'combobox' ? ( - <Select - value={formData.fieldValues[`field_${config.sdq}`] || ""} - onValueChange={(value) => handleFieldValueChange(`field_${config.sdq}`, value)} - > - <SelectTrigger> - <SelectValue placeholder="Select option" /> - </SelectTrigger> - <SelectContent> - {(comboBoxOptions[config.codeGroupId!] || []).map((option) => ( - <SelectItem key={option.id} value={option.code}> - {option.code} - {option.description} - </SelectItem> - ))} - </SelectContent> - </Select> - ) : config.documentClass ? ( - <div className="p-2 bg-gray-100 dark:bg-gray-800 rounded text-sm"> - {config.documentClass.code} - {config.documentClass.description} - </div> - ) : ( - <Input - value={formData.fieldValues[`field_${config.sdq}`] || ""} - onChange={(e) => handleFieldValueChange(`field_${config.sdq}`, e.target.value)} - placeholder="Enter value" - /> - )} - </div> - ))} - </div> - - {/* Document Number Preview */} - <div className="mt-3 p-2 bg-white dark:bg-gray-900 border rounded"> - <Label className="text-xs text-gray-600 dark:text-gray-400"> - {activeTab === "SHI" ? "Document Number" : "Project Document Number"} Preview: - </Label> - <div className="font-mono text-sm font-medium text-blue-600 dark:text-blue-400"> - {generatePreviewDocNumber()} - </div> - </div> - </div> - )} - - {/* Document Class Selection */} - <div className="grid gap-2"> - <Label htmlFor="documentClassId"> - Document Class <span className="text-red-500">*</span> + return ( + <div key={config.id} className="grid gap-2"> + <Label className="text-sm"> + {config.codeGroup?.description || config.description} + {config.required && <span className="text-red-500 ml-1">*</span>} + {config.remark && ( + <span className="text-xs text-gray-500 dark:text-gray-400 ml-2"> + ({config.remark}) + </span> + )} </Label> - <Select - value={formData.documentClassId} - onValueChange={handleDocumentClassChange} - > - <SelectTrigger> - <SelectValue placeholder="Select document class" /> - </SelectTrigger> - <SelectContent> - {documentClasses.map((cls) => ( - <SelectItem key={cls.id} value={String(cls.id)}> - {cls.value} - </SelectItem> - ))} - </SelectContent> - </Select> - {formData.documentClassId && ( - <p className="text-xs text-gray-600 dark:text-gray-400"> - Options from the selected class will be automatically created as stages. - </p> - )} - </div> - {/* Document Class Options with Plan Dates */} - {documentClassOptions.length > 0 && ( - <div className="border rounded-lg p-4 bg-green-50/30 dark:bg-green-950/30"> - <Label className="text-sm font-medium text-green-800 dark:text-green-200 mb-3 block"> - Document Class Stages with Plan Dates - </Label> - <div className="grid gap-3"> - {documentClassOptions.map((option) => ( - <div key={option.id} className="grid grid-cols-2 gap-3 items-center"> - <div> - <Label className="text-sm font-medium"> - {option.optionValue} - </Label> - {option.optionCode && ( - <p className="text-xs text-gray-500 dark:text-gray-400">Code: {option.optionCode}</p> - )} - </div> - <div className="grid gap-1"> - <Label className="text-xs text-gray-600 dark:text-gray-400">Plan Date</Label> - <Input - type="date" - value={formData.planDates[option.id] || ""} - onChange={(e) => handlePlanDateChange(option.id, e.target.value)} - className="text-sm" - /> + <Controller + name={fieldName as any} + control={form.control} + rules={{ required: config.required }} + render={({ field }) => { + if (config.codeGroup?.controlType === 'combobox') { + return ( + <Select value={field.value || ''} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="Select option" /> + </SelectTrigger> + <SelectContent> + {(comboBoxOptions[config.codeGroupId!] || []).map((option) => ( + <SelectItem key={option.id} value={option.code}> + {option.code} - {option.description} + </SelectItem> + ))} + </SelectContent> + </Select> + ) + } else if (config.documentClass) { + return ( + <div className="p-2 bg-gray-100 dark:bg-gray-800 rounded text-sm"> + {config.documentClass.code} - {config.documentClass.description} </div> - </div> - ))} - </div> - </div> - )} - - {/* Document Title */} - <div className="grid gap-2"> - <Label htmlFor="title"> - Document Title <span className="text-red-500">*</span> - </Label> - <Input - id="title" - value={formData.title} - onChange={(e) => setFormData({ ...formData, title: e.target.value })} - placeholder="Enter document title" + ) + } else { + return ( + <Input + {...field} + value={field.value || ''} + placeholder="Enter value" + /> + ) + } + }} /> </div> - </div> - ) + ) + } - // 로딩 중이거나 데이터 체크 중일 때 표시 if (isLoadingInitialData) { return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col"> + <DialogContent className="sm:max-w-[800px] h-[80vh] flex flex-col"> <div className="flex items-center justify-center py-8 flex-1"> <Loader2 className="h-8 w-8 animate-spin" /> </div> @@ -525,98 +481,239 @@ React.useEffect(() => { ) } - return ( -<Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[700px] max-h-[80vh] flex flex-col overflow-hidden"> - <DialogHeader className="flex-shrink-0"> - <DialogTitle>Add New Document</DialogTitle> - <DialogDescription> - Enter the basic information for the new document. - </DialogDescription> - </DialogHeader> - - {!shiType && !cpyType ? ( - <div className="flex-1 flex items-center justify-center"> - <Alert className="max-w-md"> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - Required Document Number Type (SHI, CPY) is not configured. Please configure it first in the Number Types management. - </AlertDescription> - </Alert> - </div> - ) : ( - <> - <Tabs - value={activeTab} - onValueChange={(v) => handleTabChange(v as "SHI" | "CPY")} - className="flex-1 min-h-0 flex flex-col" - > - {/* 고정 영역 */} - <TabsList className="grid w-full grid-cols-2 flex-shrink-0"> - <TabsTrigger value="SHI" disabled={!shiType}> - SHI (Document No.) - {!shiType && <AlertTriangle className="ml-2 h-3 w-3" />} - </TabsTrigger> - <TabsTrigger value="CPY" disabled={!cpyType}> - CPY (Project Document No.) - {!cpyType && <AlertTriangle className="ml-2 h-3 w-3" />} - </TabsTrigger> - </TabsList> - - {/* 스크롤 영역 */} - <div className="flex-1 min-h-0 mt-4 overflow-y-auto pr-2"> - <TabsContent - value="SHI" - className="data-[state=inactive]:hidden" - > - {shiType ? ( - <DocumentForm /> - ) : ( - <Alert> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - SHI Document Number Type is not configured. - </AlertDescription> - </Alert> + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[800px] max-h-[90vh] flex flex-col overflow-hidden"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle>Add New Document</DialogTitle> + <DialogDescription> + Enter the document information and generate document numbers. + </DialogDescription> + </DialogHeader> + + {!hasAvailableTypes ? ( + <div className="flex-1 flex items-center justify-center"> + <Alert className="max-w-md"> + <AlertTriangle className="h-4 w-4" /> + <AlertDescription> + Required Document Number Type (SHI, CPY) is not configured. + Please configure it first in the Number Types management. + </AlertDescription> + </Alert> + </div> + ) : ( + <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0"> + <div className="flex-1 overflow-y-auto pr-2 space-y-4"> + + {/* SHI Document Number Card */} + {shiType && ( + <Card className="border-blue-200 dark:border-blue-800"> + <CardHeader className="pb-4"> + <CardTitle className="flex items-center gap-2 text-lg"> + <FileText className="h-5 w-5 text-blue-600 dark:text-blue-400" /> + SHI Document Number + </CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + {shiTypeConfigs.length > 0 ? ( + <> + <div className="grid gap-3"> + {shiTypeConfigs.map((config) => + renderField(config, 'SHI', shiComboBoxOptions) + )} + </div> + + {/* SHI Preview */} + <div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/50 border border-blue-200 dark:border-blue-800 rounded"> + <Label className="text-xs text-blue-700 dark:text-blue-300"> + Document Number Preview: + </Label> + <div className="font-mono text-sm font-medium text-blue-800 dark:text-blue-200 mt-1"> + {generateShiPreview()} + </div> + </div> + </> + ) : ( + <div className="text-sm text-gray-500"> + Loading SHI configuration... + </div> + )} + </CardContent> + </Card> )} - </TabsContent> - <TabsContent - value="CPY" - className="data-[state=inactive]:hidden" - > - {cpyType ? ( - <DocumentForm /> - ) : ( - <Alert> - <AlertTriangle className="h-4 w-4" /> - <AlertDescription> - CPY Document Number Type is not configured. - </AlertDescription> - </Alert> + {/* CPY Project Document Number Card */} + {cpyType && ( + <Card className="border-green-200 dark:border-green-800"> + <CardHeader className="pb-4"> + <CardTitle className="flex items-center gap-2 text-lg"> + <FolderOpen className="h-5 w-5 text-green-600 dark:text-green-400" /> + CPY Project Document Number + </CardTitle> + </CardHeader> + <CardContent className="space-y-3"> + {cpyTypeConfigs.length > 0 ? ( + <> + <div className="grid gap-3"> + {cpyTypeConfigs.map((config) => + renderField(config, 'CPY', cpyComboBoxOptions) + )} + </div> + + {/* CPY Preview */} + <div className="mt-3 p-3 bg-green-50 dark:bg-green-950/50 border border-green-200 dark:border-green-800 rounded"> + <Label className="text-xs text-green-700 dark:text-green-300"> + Project Document Number Preview: + </Label> + <div className="font-mono text-sm font-medium text-green-800 dark:text-green-200 mt-1"> + {generateCpyPreview()} + </div> + </div> + </> + ) : ( + <div className="text-sm text-gray-500"> + Loading CPY configuration... + </div> + )} + </CardContent> + </Card> )} - </TabsContent> - </div> - </Tabs> - <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4"> - <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}> - Cancel - </Button> - <Button - onClick={handleSubmit} - disabled={isSubmitting || !isFormValid() || (!shiType && !cpyType)} - > - {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} - Add Document - </Button> - </DialogFooter> - </> - )} - </DialogContent> -</Dialog> + {/* Document Class Selection */} + <div className="space-y-2"> + <Label htmlFor="documentClassId"> + Document Class <span className="text-red-500">*</span> + </Label> + <Controller + name="documentClassId" + control={form.control} + render={({ field }) => ( + <Select value={field.value} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="Select document class" /> + </SelectTrigger> + <SelectContent> + {documentClasses.map((cls) => ( + <SelectItem key={cls.id} value={String(cls.id)}> + {cls.value} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + /> + {form.formState.errors.documentClassId && ( + <p className="text-xs text-red-500"> + {form.formState.errors.documentClassId.message} + </p> + )} + {documentClassId && ( + <p className="text-xs text-gray-600 dark:text-gray-400"> + Options from the selected class will be automatically created as stages. + </p> + )} + </div> + + {/* Document Class Options with Plan Dates */} + {documentClassOptions.length > 0 && ( + <Card> + <CardHeader className="pb-4"> + <CardTitle className="text-base">Document Stages with Plan Dates</CardTitle> + </CardHeader> + <CardContent> + <div className="grid gap-3"> + {documentClassOptions.map((option) => ( + <div key={option.id} className="grid grid-cols-2 gap-3 items-center"> + <div> + <Label className="text-sm font-medium"> + {option.optionValue} + </Label> + {option.optionCode && ( + <p className="text-xs text-gray-500 dark:text-gray-400"> + Code: {option.optionCode} + </p> + )} + </div> + <div className="grid gap-1"> + <Label className="text-xs text-gray-600 dark:text-gray-400"> + Plan Date + </Label> + <Controller + name={`planDates.${option.id}`} + control={form.control} + render={({ field }) => ( + <Input + type="date" + {...field} + value={field.value || ''} + className="text-sm" + /> + )} + /> + </div> + </div> + ))} + </div> + </CardContent> + </Card> + )} + + {/* Document Title */} + <div className="space-y-2"> + <Label htmlFor="title"> + Document Title <span className="text-red-500">*</span> + </Label> + <Controller + name="title" + control={form.control} + render={({ field }) => ( + <Input + {...field} + id="title" + placeholder="Enter document title" + /> + )} + /> + {form.formState.errors.title && ( + <p className="text-xs text-red-500"> + {form.formState.errors.title.message} + </p> + )} + </div> + </div> + + <DialogFooter className="flex-shrink-0 border-t pt-4 mt-4"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={form.formState.isSubmitting} + > + Cancel + </Button> + <Button + type="submit" + disabled={ + form.formState.isSubmitting || + !hasAvailableTypes || + (shiType && !isShiComplete()) || + (cpyType && !isCpyComplete()) + } + > + {form.formState.isSubmitting ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : null} + Add Document + </Button> + </DialogFooter> + </form> + )} + </DialogContent> + </Dialog> ) } + + // ============================================================================= // Edit Document Dialog (with improved stage plan date editing) // ============================================================================= @@ -690,7 +787,7 @@ export function EditDocumentDialog({ setIsLoading(true) try { const result = await updateDocument({ - documentId: document.id, + documentId: document.documentId, title: formData.title, vendorDocNumber: formData.vendorDocNumber, stagePlanDates: formData.stagePlanDates, @@ -1019,361 +1116,6 @@ export function EditStageDialog({ </Dialog> ) } -// ============================================================================= -// 4. Excel Import Dialog -// ============================================================================= -interface ExcelImportDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - contractId: number - projectType: "ship" | "plant" -} - -interface ImportResult { - documents: any[] - stages: any[] - errors: string[] - warnings: string[] -} - -export function ExcelImportDialog({ - open, - onOpenChange, - contractId, - projectType -}: ExcelImportDialogProps) { - const [file, setFile] = React.useState<File | null>(null) - const [isProcessing, setIsProcessing] = React.useState(false) - const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) - const [importResult, setImportResult] = React.useState<ImportResult | null>(null) - const [processStep, setProcessStep] = React.useState<string>("") - const [progress, setProgress] = React.useState(0) - const router = useRouter() - - const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const selectedFile = e.target.files?.[0] - if (selectedFile) { - // 파일 유효성 검사 - if (!validateFileExtension(selectedFile)) { - toast.error("Excel 파일(.xlsx, .xls)만 업로드 가능합니다.") - return - } - - if (!validateFileSize(selectedFile, 10)) { - toast.error("파일 크기는 10MB 이하여야 합니다.") - return - } - - setFile(selectedFile) - setImportResult(null) - } - } - - const validateFileExtension = (file: File): boolean => { - const allowedExtensions = ['.xlsx', '.xls'] - const fileName = file.name.toLowerCase() - return allowedExtensions.some(ext => fileName.endsWith(ext)) - } - - const validateFileSize = (file: File, maxSizeMB: number): boolean => { - const maxSizeBytes = maxSizeMB * 1024 * 1024 - return file.size <= maxSizeBytes - } - - // 템플릿 다운로드 - const handleDownloadTemplate = async () => { - setIsDownloadingTemplate(true) - try { - const workbook = await createImportTemplate(projectType, contractId) - const buffer = await workbook.xlsx.writeBuffer() - - const blob = new Blob([buffer], { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - }) - - const url = window.URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = `문서_임포트_템플릿_${projectType}_${new Date().toISOString().split('T')[0]}.xlsx` - link.click() - - window.URL.revokeObjectURL(url) - toast.success("템플릿 파일이 다운로드되었습니다.") - } catch (error) { - toast.error("템플릿 다운로드 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : "알 수 없는 오류")) - } finally { - setIsDownloadingTemplate(false) - } - } - - // 엑셀 파일 처리 - const handleImport = async () => { - if (!file) { - toast.error("파일을 선택해주세요.") - return - } - - setIsProcessing(true) - setProgress(0) - - try { - setProcessStep("파일 읽는 중...") - setProgress(20) - - const workbook = new ExcelJS.Workbook() - const buffer = await file.arrayBuffer() - await workbook.xlsx.load(buffer) - - setProcessStep("데이터 검증 중...") - setProgress(40) - - // 워크시트 확인 - const documentsSheet = workbook.getWorksheet('Documents') || workbook.getWorksheet(1) - const stagesSheet = workbook.getWorksheet('Stage Plan Dates') || workbook.getWorksheet(2) - - if (!documentsSheet) { - throw new Error("Documents 시트를 찾을 수 없습니다.") - } - - setProcessStep("문서 데이터 파싱 중...") - setProgress(60) - - // 문서 데이터 파싱 - const documentData = await parseDocumentsSheet(documentsSheet, projectType) - - setProcessStep("스테이지 데이터 파싱 중...") - setProgress(80) - - // 스테이지 데이터 파싱 (선택사항) - let stageData: any[] = [] - if (stagesSheet) { - stageData = await parseStagesSheet(stagesSheet) - } - - setProcessStep("서버에 업로드 중...") - setProgress(90) - - // 서버로 데이터 전송 - const result = await uploadImportData({ - contractId, - documents: documentData.validData, - stages: stageData, - projectType - }) - - if (result.success) { - setImportResult({ - documents: documentData.validData, - stages: stageData, - errors: documentData.errors, - warnings: result.warnings || [] - }) - setProgress(100) - toast.success(`${documentData.validData.length}개 문서가 성공적으로 임포트되었습니다.`) - } else { - throw new Error(result.error || "임포트에 실패했습니다.") - } - - } catch (error) { - toast.error(error instanceof Error ? error.message : "임포트 중 오류가 발생했습니다.") - setImportResult({ - documents: [], - stages: [], - errors: [error instanceof Error ? error.message : "알 수 없는 오류"], - warnings: [] - }) - } finally { - setIsProcessing(false) - setProcessStep("") - setProgress(0) - } - } - - const handleClose = () => { - setFile(null) - setImportResult(null) - setProgress(0) - setProcessStep("") - onOpenChange(false) - } - - const handleConfirmImport = () => { - // 페이지 새로고침하여 데이터 갱신 - router.refresh() - handleClose() - } - - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col"> - <DialogHeader className="flex-shrink-0"> - <DialogTitle> - <FileSpreadsheet className="inline w-5 h-5 mr-2" /> - Excel 파일 임포트 - </DialogTitle> - <DialogDescription> - Excel 파일을 사용하여 문서를 일괄 등록합니다. - </DialogDescription> - </DialogHeader> - - <div className="flex-1 overflow-y-auto pr-2"> - <div className="grid gap-4 py-4"> - {/* 템플릿 다운로드 섹션 */} - <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> - <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. 템플릿 다운로드</h4> - <p className="text-sm text-blue-700 dark:text-blue-300 mb-3"> - 올바른 형식과 드롭다운이 적용된 템플릿을 다운로드하세요. - </p> - <Button - variant="outline" - size="sm" - onClick={handleDownloadTemplate} - disabled={isDownloadingTemplate} - > - {isDownloadingTemplate ? ( - <Loader2 className="h-4 w-4 animate-spin mr-2" /> - ) : ( - <Download className="h-4 w-4 mr-2" /> - )} - 템플릿 다운로드 - </Button> - </div> - - {/* 파일 업로드 섹션 */} - <div className="border rounded-lg p-4"> - <h4 className="font-medium mb-2">2. 파일 업로드</h4> - <div className="grid gap-2"> - <Label htmlFor="excel-file">Excel 파일 선택</Label> - <Input - id="excel-file" - type="file" - accept=".xlsx,.xls" - onChange={handleFileChange} - disabled={isProcessing} - className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" - /> - {file && ( - <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> - 선택된 파일: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) - </p> - )} - </div> - </div> - - {/* 진행 상태 */} - {isProcessing && ( - <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30"> - <div className="flex items-center gap-2 mb-2"> - <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> - <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">처리 중...</span> - </div> - <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p> - <Progress value={progress} className="h-2" /> - </div> - )} - - {/* 임포트 결과 */} - {importResult && ( - <div className="space-y-3"> - {importResult.documents.length > 0 && ( - <Alert> - <CheckCircle className="h-4 w-4" /> - <AlertDescription> - <strong>{importResult.documents.length}개</strong> 문서가 성공적으로 임포트되었습니다. - {importResult.stages.length > 0 && ( - <> ({importResult.stages.length}개 스테이지 계획날짜 포함)</> - )} - </AlertDescription> - </Alert> - )} - - {importResult.warnings.length > 0 && ( - <Alert> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - <strong>경고:</strong> - <ul className="mt-1 list-disc list-inside"> - {importResult.warnings.map((warning, index) => ( - <li key={index} className="text-sm">{warning}</li> - ))} - </ul> - </AlertDescription> - </Alert> - )} - - {importResult.errors.length > 0 && ( - <Alert variant="destructive"> - <AlertCircle className="h-4 w-4" /> - <AlertDescription> - <strong>오류:</strong> - <ul className="mt-1 list-disc list-inside"> - {importResult.errors.map((error, index) => ( - <li key={index} className="text-sm">{error}</li> - ))} - </ul> - </AlertDescription> - </Alert> - )} - </div> - )} - - {/* 파일 형식 가이드 */} - <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> - <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">파일 형식 가이드</h4> - <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1"> - <p><strong>Documents 시트:</strong></p> - <ul className="ml-4 list-disc"> - <li>Document Number* (문서번호)</li> - <li>Document Name* (문서명)</li> - <li>Document Class* (문서클래스 - 드롭다운 선택)</li> - {projectType === "plant" && ( - <li>Project Doc No. (벤더문서번호)</li> - )} - </ul> - <p className="mt-2"><strong>Stage Plan Dates 시트 (선택사항):</strong></p> - <ul className="ml-4 list-disc"> - <li>Document Number* (문서번호)</li> - <li>Stage Name* (스테이지명 - 드롭다운 선택, 해당 문서클래스에 맞는 스테이지만 선택)</li> - <li>Plan Date (계획날짜: YYYY-MM-DD)</li> - </ul> - <p className="mt-2 text-green-600 dark:text-green-400"><strong>스마트 기능:</strong></p> - <ul className="ml-4 list-disc text-green-600 dark:text-green-400"> - <li>Document Class는 드롭다운으로 정확한 값만 선택 가능</li> - <li>Stage Name도 드롭다운으로 오타 방지</li> - <li>"사용 가이드" 시트에서 각 클래스별 사용 가능한 스테이지 확인 가능</li> - </ul> - <p className="mt-2 text-red-600 dark:text-red-400">* 필수 항목</p> - </div> - </div> - </div> - </div> - - <DialogFooter className="flex-shrink-0"> - <Button variant="outline" onClick={handleClose}> - {importResult ? "닫기" : "취소"} - </Button> - {!importResult ? ( - <Button - onClick={handleImport} - disabled={!file || isProcessing} - > - {isProcessing ? ( - <Loader2 className="h-4 w-4 animate-spin mr-2" /> - ) : ( - <Upload className="h-4 w-4 mr-2" /> - )} - {isProcessing ? "처리 중..." : "임포트 시작"} - </Button> - ) : importResult.documents.length > 0 ? ( - <Button onClick={handleConfirmImport}> - 완료 및 새로고침 - </Button> - ) : null} - </DialogFooter> - </DialogContent> - </Dialog> - ) -} // ============================================================================= // 5. Delete Documents Confirmation Dialog @@ -1399,6 +1141,7 @@ export function DeleteDocumentsDialog({ function onDelete() { startDeleteTransition(async () => { + const { error } = await deleteDocuments({ ids: documents.map((document) => document.documentId), }) @@ -1500,223 +1243,3 @@ export function DeleteDocumentsDialog({ ) } -// ============================================================================= -// Helper Functions for Excel Import -// ============================================================================= - -// ExcelJS 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...) -function getExcelColumnName(index: number): string { - let result = "" - while (index > 0) { - index-- - result = String.fromCharCode(65 + (index % 26)) + result - index = Math.floor(index / 26) - } - return result -} - -// 헤더 행 스타일링 함수 -function styleHeaderRow(headerRow: ExcelJS.Row, bgColor: string = 'FF4472C4') { - headerRow.eachCell((cell) => { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: bgColor } - } - cell.font = { - color: { argb: 'FFFFFFFF' }, - bold: true - } - cell.alignment = { - horizontal: 'center', - vertical: 'middle' - } - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - if (String(cell.value).includes('*')) { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE74C3C' } - } - } - }) -} - -// 템플릿 생성 함수 -async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { - const res = await getDocumentClassOptionsByContract(contractId); - if (!res.success) throw new Error(res.error || "데이터 로딩 실패"); - - const documentClasses = res.data.classes; // [{id, code, description}] - const options = res.data.options; // [{documentClassId, optionValue, ...}] - - // 클래스별 옵션 맵 - const optionsByClassId = new Map<number, string[]>(); - for (const c of documentClasses) optionsByClassId.set(c.id, []); - for (const o of options) { - optionsByClassId.get(o.documentClassId)?.push(o.optionValue); - } - - // 모든 스테이지 명 (유니크) - const allStageNames = Array.from(new Set(options.map(o => o.optionValue))); - - const workbook = new ExcelJS.Workbook(); - - // ================= ReferenceData (hidden) ================= - const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }); - - // A열: DocumentClasses - referenceSheet.getCell("A1").value = "DocumentClasses"; - documentClasses.forEach((docClass, i) => { - referenceSheet.getCell(`A${i + 2}`).value = `${docClass.description}`; - }); - - // B열부터: 각 클래스의 Stage 옵션 - let currentCol = 2; // B - for (const docClass of documentClasses) { - const colLetter = getExcelColumnName(currentCol); - referenceSheet.getCell(`${colLetter}1`).value = docClass.description; - - const list = optionsByClassId.get(docClass.id) ?? []; - list.forEach((v, i) => { - referenceSheet.getCell(`${colLetter}${i + 2}`).value = v; - }); - - currentCol++; - } - - // 마지막 열: AllStageNames - const allStagesCol = getExcelColumnName(currentCol); - referenceSheet.getCell(`${allStagesCol}1`).value = "AllStageNames"; - allStageNames.forEach((v, i) => { - referenceSheet.getCell(`${allStagesCol}${i + 2}`).value = v; - }); - - // ================= Documents ================= - const documentsSheet = workbook.addWorksheet("Documents"); - const documentHeaders = [ - "Document Number*", - "Document Name*", - "Document Class*", - ...(projectType === "plant" ? ["Project Doc No."] : []), - "Notes", - ]; - const documentHeaderRow = documentsSheet.addRow(documentHeaders); - styleHeaderRow(documentHeaderRow); - - const sampleDocumentData = - projectType === "ship" - ? [ - "SH-2024-001", - "기본 설계 도면", - documentClasses[0] - ? `${documentClasses[0].description}` - : "", - "참고사항", - ] - : [ - "PL-2024-001", - "공정 설계 도면", - documentClasses[0] - ? `${documentClasses[0].description}` - : "", - "V-001", - "참고사항", - ]; - - documentsSheet.addRow(sampleDocumentData); - - // Document Class 드롭다운 - const docClassColIndex = 3; // C - const docClassCol = getExcelColumnName(docClassColIndex); - documentsSheet.dataValidations.add(`${docClassCol}2:${docClassCol}1000`, { - type: "list", - allowBlank: false, - formulae: [`ReferenceData!$A$2:$A$${documentClasses.length + 1}`], - }); - - documentsSheet.columns = [ - { width: 15 }, - { width: 25 }, - { width: 28 }, - ...(projectType === "plant" ? [{ width: 18 }] : []), - { width: 24 }, - ]; - - // ================= Stage Plan Dates ================= - const stagesSheet = workbook.addWorksheet("Stage Plan Dates"); - const stageHeaderRow = stagesSheet.addRow(["Document Number*", "Stage Name*", "Plan Date"]); - styleHeaderRow(stageHeaderRow, "FF27AE60"); - - const firstClass = documentClasses[0]; - const firstClassOpts = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : []; - - const sampleStageData = [ - [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[0] ?? "", "2024-02-15"], - [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[1] ?? "", "2024-03-01"], - ]; - - sampleStageData.forEach(row => { - const r = stagesSheet.addRow(row); - r.getCell(3).numFmt = "yyyy-mm-dd"; - }); - - // Stage Name 드롭다운 (전체) - stagesSheet.dataValidations.add("B2:B1000", { - type: "list", - allowBlank: false, - formulae: [`ReferenceData!$${allStagesCol}$2:$${allStagesCol}$${allStageNames.length + 1}`], - promptTitle: "Stage Name 선택", - prompt: "Document의 Document Class에 해당하는 Stage Name을 선택하세요.", - }); - - stagesSheet.columns = [{ width: 15 }, { width: 30 }, { width: 12 }]; - - // ================= 사용 가이드 ================= - const guideSheet = workbook.addWorksheet("사용 가이드"); - const guideContent: (string[])[] = [ - ["문서 임포트 가이드"], - [""], - ["1. Documents 시트"], - [" - Document Number*: 고유한 문서 번호를 입력하세요"], - [" - Document Name*: 문서명을 입력하세요"], - [" - Document Class*: 드롭다운에서 문서 클래스를 선택하세요"], - [" - Project Doc No.: 벤더 문서 번호"], - [" - Notes: 참고사항"], - [""], - ["2. Stage Plan Dates 시트 (선택사항)"], - [" - Document Number*: Documents 시트의 Document Number와 일치해야 합니다"], - [" - Stage Name*: 드롭다운에서 해당 문서 클래스에 맞는 스테이지명을 선택하세요"], - [" - Plan Date: 계획 날짜 (YYYY-MM-DD 형식)"], - [""], - ["3. 주의사항"], - [" - * 표시는 필수 항목입니다"], - [" - Document Number는 고유해야 합니다"], - [" - Stage Name은 해당 Document의 Document Class에 속한 것만 유효합니다"], - [" - 날짜는 YYYY-MM-DD 형식으로 입력하세요"], - [""], - ["4. Document Class별 사용 가능한 Stage Names"], - [""], - ]; - - for (const c of documentClasses) { - guideContent.push([`${c.code} - ${c.description}:`]); - (optionsByClassId.get(c.id) ?? []).forEach(v => guideContent.push([` • ${v}`])); - guideContent.push([""]); - } - - guideContent.forEach((row, i) => { - const r = guideSheet.addRow(row); - if (i === 0) r.getCell(1).font = { bold: true, size: 14 }; - else if (row[0] && row[0].includes(":") && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true }; - }); - guideSheet.getColumn(1).width = 60; - - return workbook; -}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx index ccb9e15c..f676e1fc 100644 --- a/lib/vendor-document-list/plant/document-stage-toolbar.tsx +++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx @@ -12,13 +12,13 @@ import { Button } from "@/components/ui/button" import { DeleteDocumentsDialog, AddDocumentDialog, - ExcelImportDialog } from "./document-stage-dialogs" import { sendDocumentsToSHI } from "./document-stages-service" import { useDocumentPolling } from "@/hooks/use-document-polling" import { cn } from "@/lib/utils" import { MultiUploadDialog } from "./upload/components/multi-upload-dialog" import { useRouter } from "next/navigation" +import { ExcelImportDialog } from "./excel-import-stage" // 서버 액션 import (필요한 경우) // import { importDocumentsExcel } from "./document-stages-service" diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx index 0b85c3f8..d71ecc0f 100644 --- a/lib/vendor-document-list/plant/document-stages-columns.tsx +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -347,166 +347,166 @@ export function getDocumentStagesColumns({ }, }, - { - accessorKey: "buyerSystemStatus", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="SHI Status" /> - ), - cell: ({ row }) => { - const doc = row.original - const getBuyerStatusBadge = () => { - if (!doc.buyerSystemStatus) { - return <Badge variant="outline">Not Recieved</Badge> - } + // { + // accessorKey: "buyerSystemStatus", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="SHI Status" /> + // ), + // cell: ({ row }) => { + // const doc = row.original + // const getBuyerStatusBadge = () => { + // if (!doc.buyerSystemStatus) { + // return <Badge variant="outline">Not Recieved</Badge> + // } - switch (doc.buyerSystemStatus) { - case '승인(DC)': - return <Badge variant="success">Approved</Badge> - case '검토중': - return <Badge variant="default">검토중</Badge> - case '반려': - return <Badge variant="destructive">반려</Badge> - default: - return <Badge variant="secondary">{doc.buyerSystemStatus}</Badge> - } - } + // switch (doc.buyerSystemStatus) { + // case '승인(DC)': + // return <Badge variant="success">Approved</Badge> + // case '검토중': + // return <Badge variant="default">검토중</Badge> + // case '반려': + // return <Badge variant="destructive">반려</Badge> + // default: + // return <Badge variant="secondary">{doc.buyerSystemStatus}</Badge> + // } + // } - return ( - <div className="flex flex-col gap-1"> - {getBuyerStatusBadge()} - {doc.buyerSystemComment && ( - <Tooltip> - <TooltipTrigger> - <MessageSquare className="h-3 w-3 text-muted-foreground" /> - </TooltipTrigger> - <TooltipContent> - <p className="max-w-xs">{doc.buyerSystemComment}</p> - </TooltipContent> - </Tooltip> - )} - </div> - ) - }, - size: 120, - }, - { - accessorKey: "currentStageName", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Current Stage" /> - ), - cell: ({ row }) => { - const doc = row.original - // if (!doc.currentStageName) { - // return ( - // <Button - // size="sm" - // variant="outline" - // onClick={(e) => { - // e.stopPropagation() - // setRowAction({ row, type: "add_stage" }) - // }} - // className="h-6 text-xs" - // > - // <Plus className="w-3 h-3 mr-1" /> - // Add stage - // </Button> - // ) - // } - - return ( - <div className="flex items-center gap-2"> - <span className="text-sm font-medium truncate" title={doc.currentStageName}> - {doc.currentStageName} - </span> - <Badge - variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)} - className="text-xs px-1.5 py-0" - > - {getStatusText(doc.currentStageStatus || '')} - </Badge> - {doc.currentStageAssigneeName && ( - <span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1"> - <User className="w-3 h-3" /> - {doc.currentStageAssigneeName} - </span> - )} - </div> - ) - }, - size: 180, - enableResizing: true, - meta: { - excelHeader: "Current Stage" - }, - }, - - // 계획 일정 (한 줄) - { - accessorKey: "currentStagePlanDate", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Plan Date" /> - ), - cell: ({ row }) => { - const doc = row.original - if (!doc.currentStagePlanDate) return <span className="text-gray-400 dark:text-gray-500">-</span> - - return ( - <div className="flex items-center gap-2"> - <span className="text-sm">{formatDate(doc.currentStagePlanDate)}</span> - <DueDateInfo - daysUntilDue={doc.daysUntilDue} - isOverdue={doc.isOverdue || false} - /> - </div> - ) - }, - size: 150, - enableResizing: true, - meta: { - excelHeader: "Plan Date" - }, - }, - - // 우선순위 + 진행률 (콤팩트) - { - accessorKey: "progressPercentage", - header: ({ column }) => ( - <DataTableColumnHeaderSimple column={column} title="Priority/Progress" /> - ), - cell: ({ row }) => { - const doc = row.original - const progress = doc.progressPercentage || 0 - const completed = doc.completedStages || 0 - const total = doc.totalStages || 0 - - return ( - <div className="flex items-center gap-2"> - {doc.currentStagePriority && ( - <Badge - variant={getPriorityColor(doc.currentStagePriority)} - className="text-xs px-1.5 py-0" - > - {getPriorityText(doc.currentStagePriority)} - </Badge> - )} - <div className="flex items-center gap-1"> - <Progress value={progress} className="w-12 h-1.5" /> - <span className="text-xs text-gray-600 dark:text-gray-400 min-w-[2rem]"> - {progress}% - </span> - </div> - <span className="text-xs text-gray-500 dark:text-gray-400"> - ({completed}/{total}) - </span> - </div> - ) - }, - size: 140, - enableResizing: true, - meta: { - excelHeader: "Progress" - }, - }, + // return ( + // <div className="flex flex-col gap-1"> + // {getBuyerStatusBadge()} + // {doc.buyerSystemComment && ( + // <Tooltip> + // <TooltipTrigger> + // <MessageSquare className="h-3 w-3 text-muted-foreground" /> + // </TooltipTrigger> + // <TooltipContent> + // <p className="max-w-xs">{doc.buyerSystemComment}</p> + // </TooltipContent> + // </Tooltip> + // )} + // </div> + // ) + // }, + // size: 120, + // }, + // { + // accessorKey: "currentStageName", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Current Stage" /> + // ), + // cell: ({ row }) => { + // const doc = row.original + // // if (!doc.currentStageName) { + // // return ( + // // <Button + // // size="sm" + // // variant="outline" + // // onClick={(e) => { + // // e.stopPropagation() + // // setRowAction({ row, type: "add_stage" }) + // // }} + // // className="h-6 text-xs" + // // > + // // <Plus className="w-3 h-3 mr-1" /> + // // Add stage + // // </Button> + // // ) + // // } + + // return ( + // <div className="flex items-center gap-2"> + // <span className="text-sm font-medium truncate" title={doc.currentStageName}> + // {doc.currentStageName} + // </span> + // <Badge + // variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)} + // className="text-xs px-1.5 py-0" + // > + // {getStatusText(doc.currentStageStatus || '')} + // </Badge> + // {doc.currentStageAssigneeName && ( + // <span className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1"> + // <User className="w-3 h-3" /> + // {doc.currentStageAssigneeName} + // </span> + // )} + // </div> + // ) + // }, + // size: 180, + // enableResizing: true, + // meta: { + // excelHeader: "Current Stage" + // }, + // }, + + // // 계획 일정 (한 줄) + // { + // accessorKey: "currentStagePlanDate", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Plan Date" /> + // ), + // cell: ({ row }) => { + // const doc = row.original + // if (!doc.currentStagePlanDate) return <span className="text-gray-400 dark:text-gray-500">-</span> + + // return ( + // <div className="flex items-center gap-2"> + // <span className="text-sm">{formatDate(doc.currentStagePlanDate)}</span> + // <DueDateInfo + // daysUntilDue={doc.daysUntilDue} + // isOverdue={doc.isOverdue || false} + // /> + // </div> + // ) + // }, + // size: 150, + // enableResizing: true, + // meta: { + // excelHeader: "Plan Date" + // }, + // }, + + // // 우선순위 + 진행률 (콤팩트) + // { + // accessorKey: "progressPercentage", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Priority/Progress" /> + // ), + // cell: ({ row }) => { + // const doc = row.original + // const progress = doc.progressPercentage || 0 + // const completed = doc.completedStages || 0 + // const total = doc.totalStages || 0 + + // return ( + // <div className="flex items-center gap-2"> + // {doc.currentStagePriority && ( + // <Badge + // variant={getPriorityColor(doc.currentStagePriority)} + // className="text-xs px-1.5 py-0" + // > + // {getPriorityText(doc.currentStagePriority)} + // </Badge> + // )} + // <div className="flex items-center gap-1"> + // <Progress value={progress} className="w-12 h-1.5" /> + // <span className="text-xs text-gray-600 dark:text-gray-400 min-w-[2rem]"> + // {progress}% + // </span> + // </div> + // <span className="text-xs text-gray-500 dark:text-gray-400"> + // ({completed}/{total}) + // </span> + // </div> + // ) + // }, + // size: 140, + // enableResizing: true, + // meta: { + // excelHeader: "Progress" + // }, + // }, // 업데이트 일시 { diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 2c65b4e6..77a03aae 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -4,7 +4,7 @@ import { revalidatePath, revalidateTag } from "next/cache" import { redirect } from "next/navigation" import db from "@/db/db" -import {stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema" +import { stageSubmissionView, codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, stageIssueStages, stageDocuments, stageDocumentsView } from "@/db/schema" import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm" import { createDocumentSchema, @@ -33,6 +33,7 @@ import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { ShiBuyerSystemAPI } from "./shi-buyer-system-api" +import ExcelJS from 'exceljs' interface UpdateDocumentData { documentId: number @@ -47,13 +48,13 @@ export async function updateDocument(data: UpdateDocumentData) { try { // 1. 문서 기본 정보 업데이트 const [updatedDocument] = await db - .update(documents) + .update(stageDocuments) .set({ title: data.title, vendorDocNumber: data.vendorDocNumber || null, updatedAt: new Date(), }) - .where(eq(documents.id, data.documentId)) + .where(eq(stageDocuments.id, data.documentId)) .returning() if (!updatedDocument) { @@ -63,12 +64,12 @@ export async function updateDocument(data: UpdateDocumentData) { // 2. 스테이지들의 plan date 업데이트 const stageUpdatePromises = Object.entries(data.stagePlanDates).map(([stageId, planDate]) => { return db - .update(issueStages) + .update(stageIssueStages) .set({ planDate: planDate || null, updatedAt: new Date(), }) - .where(eq(issueStages.id, Number(stageId))) + .where(eq(stageIssueStages.id, Number(stageId))) }) await Promise.all(stageUpdatePromises) @@ -93,8 +94,8 @@ export async function deleteDocument(input: { id: number }) { const validatedData = deleteDocumentSchema.parse(input) // 문서 존재 확인 - const existingDoc = await db.query.documents.findFirst({ - where: eq(documents.id, validatedData.id) + const existingDoc = await db.query.stageDocuments.findFirst({ + where: eq(stageDocuments.id, validatedData.id) }) if (!existingDoc) { @@ -102,8 +103,8 @@ export async function deleteDocument(input: { id: number }) { } // 연관된 스테이지 확인 - const relatedStages = await db.query.issueStages.findMany({ - where: eq(issueStages.documentId, validatedData.id) + const relatedStages = await db.query.stageIssueStages.findMany({ + where: eq(stageIssueStages.documentId, validatedData.id) }) if (relatedStages.length > 0) { @@ -112,16 +113,12 @@ export async function deleteDocument(input: { id: number }) { // 소프트 삭제 (상태 변경) await db - .update(documents) + .update(stageDocuments) .set({ status: "DELETED", updatedAt: new Date(), }) - .where(eq(documents.id, validatedData.id)) - - // 캐시 무효화 - revalidateTag(`documents-${existingDoc.contractId}`) - revalidatePath(`/contracts/${existingDoc.contractId}/documents`) + .where(eq(stageDocuments.id, validatedData.id)) return { success: true, @@ -143,16 +140,17 @@ interface DeleteDocumentsData { export async function deleteDocuments(data: DeleteDocumentsData) { try { + if (data.ids.length === 0) { return { success: false, error: "삭제할 문서가 선택되지 않았습니다." } } /* 1. 요청한 문서가 존재하는지 확인 ------------------------------------ */ const existingDocs = await db - .select({ id: documents.id, docNumber: documents.docNumber }) - .from(documents) + .select({ id: stageDocuments.id, docNumber: stageDocuments.docNumber }) + .from(stageDocuments) .where(and( - inArray(documents.id, data.ids), + inArray(stageDocuments.id, data.ids), )) if (existingDocs.length === 0) { @@ -168,9 +166,9 @@ export async function deleteDocuments(data: DeleteDocumentsData) { /* 2. 연관 스테이지 건수 파악(로그·메시지용) --------------------------- */ const relatedStages = await db - .select({ documentId: issueStages.documentId }) - .from(issueStages) - .where(inArray(issueStages.documentId, data.ids)) + .select({ documentId: stageIssueStages.documentId }) + .from(stageIssueStages) + .where(inArray(stageIssueStages.documentId, data.ids)) const stagesToDelete = relatedStages.length @@ -178,17 +176,17 @@ export async function deleteDocuments(data: DeleteDocumentsData) { // ─> FK에 ON DELETE CASCADE 가 있다면 생략 가능. if (stagesToDelete > 0) { await db - .delete(issueStages) - .where(inArray(issueStages.documentId, data.ids)) + .delete(stageIssueStages) + .where(inArray(stageIssueStages.documentId, data.ids)) } /* 4. 문서 하드 삭제 --------------------------------------------------- */ const deletedDocs = await db - .delete(documents) + .delete(stageDocuments) .where(and( - inArray(documents.id, data.ids), + inArray(stageDocuments.id, data.ids), )) - .returning({ id: documents.id, docNumber: documents.docNumber }) + .returning({ id: stageDocuments.id, docNumber: stageDocuments.docNumber }) /* 5. 캐시 무효화 ------------------------------------------------------ */ @@ -225,8 +223,8 @@ export async function createStage(input: CreateStageInput) { const validatedData = createStageSchema.parse(input) // 문서 존재 확인 - const document = await db.query.documents.findFirst({ - where: eq(documents.id, validatedData.documentId) + const document = await db.query.stageDocuments.findFirst({ + where: eq(stageDocuments.id, validatedData.documentId) }) if (!document) { @@ -234,10 +232,10 @@ export async function createStage(input: CreateStageInput) { } // 스테이지명 중복 검사 - const existingStage = await db.query.issueStages.findFirst({ + const existingStage = await db.query.stageIssueStages.findFirst({ where: and( - eq(issueStages.documentId, validatedData.documentId), - eq(issueStages.stageName, validatedData.stageName) + eq(stageIssueStages.documentId, validatedData.documentId), + eq(stageIssueStages.stageName, validatedData.stageName) ) }) @@ -249,15 +247,15 @@ export async function createStage(input: CreateStageInput) { let stageOrder = validatedData.stageOrder if (stageOrder === 0 || stageOrder === undefined) { const maxOrderResult = await db - .select({ maxOrder: max(issueStages.stageOrder) }) - .from(issueStages) - .where(eq(issueStages.documentId, validatedData.documentId)) + .select({ maxOrder: max(stageIssueStages.stageOrder) }) + .from(stageIssueStages) + .where(eq(stageIssueStages.documentId, validatedData.documentId)) stageOrder = (maxOrderResult[0]?.maxOrder ?? -1) + 1 } // 스테이지 생성 - const [newStage] = await db.insert(issueStages).values({ + const [newStage] = await db.insert(stageIssueStages).values({ documentId: validatedData.documentId, stageName: validatedData.stageName, planDate: validatedData.planDate || null, @@ -273,10 +271,6 @@ export async function createStage(input: CreateStageInput) { updatedAt: new Date(), }).returning() - // 캐시 무효화 - revalidateTag(`documents-${document.contractId}`) - revalidateTag(`document-${validatedData.documentId}`) - revalidatePath(`/contracts/${document.contractId}/documents`) return { success: true, @@ -301,8 +295,8 @@ export async function updateStage(input: UpdateStageInput) { const validatedData = updateStageSchema.parse(input) // 스테이지 존재 확인 - const existingStage = await db.query.issueStages.findFirst({ - where: eq(issueStages.id, validatedData.id), + const existingStage = await db.query.stageIssueStages.findFirst({ + where: eq(stageIssueStages.id, validatedData.id), with: { document: true } @@ -314,10 +308,10 @@ export async function updateStage(input: UpdateStageInput) { // 스테이지명 중복 검사 (스테이지명 변경 시) if (validatedData.stageName && validatedData.stageName !== existingStage.stageName) { - const duplicateStage = await db.query.issueStages.findFirst({ + const duplicateStage = await db.query.stageIssueStages.findFirst({ where: and( - eq(issueStages.documentId, existingStage.documentId), - eq(issueStages.stageName, validatedData.stageName) + eq(stageIssueStages.documentId, existingStage.documentId), + eq(stageIssueStages.stageName, validatedData.stageName) ) }) @@ -328,18 +322,14 @@ export async function updateStage(input: UpdateStageInput) { // 스테이지 업데이트 const [updatedStage] = await db - .update(issueStages) + .update(stageIssueStages) .set({ ...validatedData, updatedAt: new Date(), }) - .where(eq(issueStages.id, validatedData.id)) + .where(eq(stageIssueStages.id, validatedData.id)) .returning() - // 캐시 무효화 - revalidateTag(`documents-${existingStage.document.contractId}`) - revalidateTag(`document-${existingStage.documentId}`) - revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) return { success: true, @@ -364,8 +354,8 @@ export async function deleteStage(input: { id: number }) { const validatedData = deleteStageSchema.parse(input) // 스테이지 존재 확인 - const existingStage = await db.query.issueStages.findFirst({ - where: eq(issueStages.id, validatedData.id), + const existingStage = await db.query.stageIssueStages.findFirst({ + where: eq(stageIssueStages.id, validatedData.id), with: { document: true } @@ -385,28 +375,23 @@ export async function deleteStage(input: { id: number }) { // } // 스테이지 삭제 - await db.delete(issueStages).where(eq(issueStages.id, validatedData.id)) + await db.delete(stageIssueStages).where(eq(stageIssueStages.id, validatedData.id)) // 스테이지 순서 재정렬 - const remainingStages = await db.query.issueStages.findMany({ - where: eq(issueStages.documentId, existingStage.documentId), - orderBy: [issueStages.stageOrder] + const remainingStages = await db.query.stageIssueStages.findMany({ + where: eq(stageIssueStages.documentId, existingStage.documentId), + orderBy: [stageIssueStages.stageOrder] }) for (let i = 0; i < remainingStages.length; i++) { if (remainingStages[i].stageOrder !== i) { await db - .update(issueStages) + .update(stageIssueStages) .set({ stageOrder: i, updatedAt: new Date() }) - .where(eq(issueStages.id, remainingStages[i].id)) + .where(eq(stageIssueStages.id, remainingStages[i].id)) } } - // 캐시 무효화 - revalidateTag(`documents-${existingStage.document.contractId}`) - revalidateTag(`document-${existingStage.documentId}`) - revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) - return { success: true, message: "스테이지가 성공적으로 삭제되었습니다" @@ -432,8 +417,8 @@ export async function reorderStages(input: any) { validateStageOrder(validatedData.stages) // 문서 존재 확인 - const document = await db.query.documents.findFirst({ - where: eq(documents.id, validatedData.documentId) + const document = await db.query.stageDocuments.findFirst({ + where: eq(stageDocuments.id, validatedData.documentId) }) if (!document) { @@ -442,10 +427,10 @@ export async function reorderStages(input: any) { // 스테이지들이 해당 문서에 속하는지 확인 const stageIds = validatedData.stages.map(s => s.id) - const existingStages = await db.query.issueStages.findMany({ + const existingStages = await db.query.stageIssueStages.findMany({ where: and( - eq(issueStages.documentId, validatedData.documentId), - inArray(issueStages.id, stageIds) + eq(stageIssueStages.documentId, validatedData.documentId), + inArray(stageIssueStages.id, stageIds) ) }) @@ -457,19 +442,15 @@ export async function reorderStages(input: any) { await db.transaction(async (tx) => { for (const stage of validatedData.stages) { await tx - .update(issueStages) + .update(stageIssueStages) .set({ stageOrder: stage.stageOrder, updatedAt: new Date() }) - .where(eq(issueStages.id, stage.id)) + .where(eq(stageIssueStages.id, stage.id)) } }) - // 캐시 무효화 - revalidateTag(`documents-${document.contractId}`) - revalidateTag(`document-${validatedData.documentId}`) - revalidatePath(`/contracts/${document.contractId}/documents`) return { success: true, @@ -497,7 +478,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult const validatedData = bulkCreateDocumentsSchema.parse(input) const result: ExcelImportResult = { - totalRows: validatedData.documents.length, + totalRows: validatedData.stageDocuments.length, successCount: 0, failureCount: 0, errors: [], @@ -506,16 +487,15 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult // 트랜잭션으로 일괄 처리 await db.transaction(async (tx) => { - for (let i = 0; i < validatedData.documents.length; i++) { - const docData = validatedData.documents[i] + for (let i = 0; i < validatedData.stageDocuments.length; i++) { + const docData = validatedData.stageDocuments[i] try { // 문서번호 중복 검사 - const existingDoc = await tx.query.documents.findFirst({ + const existingDoc = await tx.query.stageDocuments.findFirst({ where: and( - eq(documents.contractId, validatedData.contractId), - eq(documents.docNumber, docData.docNumber), - eq(documents.status, "ACTIVE") + eq(stageDocuments.docNumber, docData.docNumber), + eq(stageDocuments.status, "ACTIVE") ) }) @@ -530,7 +510,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult } // 문서 생성 - const [newDoc] = await tx.insert(documents).values({ + const [newDoc] = await tx.insert(stageDocuments).values({ contractId: validatedData.contractId, docNumber: docData.docNumber, title: docData.title, @@ -566,9 +546,6 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult } }) - // 캐시 무효화 - revalidateTag(`documents-${validatedData.contractId}`) - revalidatePath(`/contracts/${validatedData.contractId}/documents`) return result @@ -586,8 +563,8 @@ export async function bulkUpdateStageStatus(input: any) { const validatedData = bulkUpdateStatusSchema.parse(input) // 스테이지들 존재 확인 - const existingStages = await db.query.issueStages.findMany({ - where: inArray(issueStages.id, validatedData.stageIds), + const existingStages = await db.query.stageIssueStages.findMany({ + where: inArray(stageIssueStages.id, validatedData.stageIds), with: { document: true } }) @@ -597,20 +574,15 @@ export async function bulkUpdateStageStatus(input: any) { // 일괄 업데이트 await db - .update(issueStages) + .update(stageIssueStages) .set({ stageStatus: validatedData.status, actualDate: validatedData.actualDate || null, updatedAt: new Date() }) - .where(inArray(issueStages.id, validatedData.stageIds)) + .where(inArray(stageIssueStages.id, validatedData.stageIds)) // 관련된 계약들의 캐시 무효화 - const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] - for (const contractId of contractIds) { - revalidateTag(`documents-${contractId}`) - revalidatePath(`/contracts/${contractId}/documents`) - } return { success: true, @@ -634,8 +606,8 @@ export async function bulkAssignStages(input: any) { const validatedData = bulkAssignSchema.parse(input) // 스테이지들 존재 확인 - const existingStages = await db.query.issueStages.findMany({ - where: inArray(issueStages.id, validatedData.stageIds), + const existingStages = await db.query.stageIssueStages.findMany({ + where: inArray(stageIssueStages.id, validatedData.stageIds), with: { document: true } }) @@ -645,20 +617,14 @@ export async function bulkAssignStages(input: any) { // 일괄 담당자 지정 await db - .update(issueStages) + .update(stageIssueStages) .set({ assigneeId: validatedData.assigneeId || null, assigneeName: validatedData.assigneeName || null, updatedAt: new Date() }) - .where(inArray(issueStages.id, validatedData.stageIds)) + .where(inArray(stageIssueStages.id, validatedData.stageIds)) - // 관련된 계약들의 캐시 무효화 - const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] - for (const contractId of contractIds) { - revalidateTag(`documents-${contractId}`) - revalidatePath(`/contracts/${contractId}/documents`) - } return { success: true, @@ -689,12 +655,12 @@ export async function getDocumentNumberTypes(contractId: number) { } } - console.log(project,"project") + console.log(project, "project") const types = await db .select() .from(documentNumberTypes) - .where(and (eq(documentNumberTypes.isActive, true), eq(documentNumberTypes.projectId,project.projectId))) + .where(and(eq(documentNumberTypes.isActive, true), eq(documentNumberTypes.projectId, project.projectId))) .orderBy(asc(documentNumberTypes.name)) return { success: true, data: types } @@ -735,7 +701,7 @@ export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) .where( and( eq(documentNumberTypeConfigs.documentNumberTypeId, documentNumberTypeId), - eq(documentNumberTypeConfigs.isActive, true), + eq(documentNumberTypeConfigs.isActive, true), // eq(documentNumberTypeConfigs.projectId, project.projectId) ) ) @@ -772,9 +738,9 @@ export async function getComboBoxOptions(codeGroupId: number) { } // 문서 클래스 목록 조회 -export async function getDocumentClasses(contractId:number) { +export async function getDocumentClasses(contractId: number) { try { - const projectId = await db.query.contracts.findFirst({ + const projectId = await db.query.contracts.findFirst({ where: eq(contracts.id, contractId), }); @@ -788,10 +754,10 @@ export async function getDocumentClasses(contractId:number) { .select() .from(documentClasses) .where( - and( - eq(documentClasses.isActive, true), - eq(documentClasses.projectId, projectId.projectId) - ) + and( + eq(documentClasses.isActive, true), + eq(documentClasses.projectId, projectId.projectId) + ) ) .orderBy(asc(documentClasses.description)) @@ -871,7 +837,7 @@ export async function getDocumentClassOptionsByContract(contractId: number) { eq(documentClassOptions.isActive, true) ) ); - // 필요하면 .orderBy(asc(documentClassOptions.sortOrder ?? documentClassOptions.optionValue)) + // 필요하면 .orderBy(asc(documentClassOptions.sortOrder ?? documentClassOptions.optionValue)) return { success: true, data: { classes, options } }; } catch (error) { @@ -924,7 +890,7 @@ export async function createDocument(data: CreateDocumentData) { }, }) - console.log(contract,"contract") + console.log(contract, "contract") if (!contract) { return { success: false, error: "유효하지 않은 계약(ID)입니다." } @@ -944,7 +910,7 @@ export async function createDocument(data: CreateDocumentData) { /* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */ const insertData = { // 필수 - projectId, + projectId, vendorId, // ★ 새로 추가 contractId: data.contractId, docNumber: data.docNumber, @@ -954,7 +920,7 @@ export async function createDocument(data: CreateDocumentData) { updatedAt: new Date(), // 선택 - vendorDocNumber: data.vendorDocNumber === null || data.vendorDocNumber ==='' ? null: data.vendorDocNumber , + vendorDocNumber: data.vendorDocNumber === null || data.vendorDocNumber === '' ? null : data.vendorDocNumber, } @@ -984,7 +950,7 @@ export async function createDocument(data: CreateDocumentData) { ) - console.log(data.documentClassId,"documentClassId") + console.log(data.documentClassId, "documentClassId") console.log(stageOptionsResult.data) if (stageOptionsResult.success && stageOptionsResult.data.length > 0) { @@ -1045,7 +1011,7 @@ export async function getDocumentStagesOnly( // 세션에서 도메인 정보 가져오기 const session = await getServerSession(authOptions) const isEvcpDomain = session?.user?.domain === "evcp" - + // 도메인별 WHERE 조건 설정 let finalWhere if (isEvcpDomain) { @@ -1065,14 +1031,14 @@ export async function getDocumentStagesOnly( - // 정렬 처리 + // 정렬 처리 const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => item.desc ? desc(stageDocumentsView[item.id]) : asc(stageDocumentsView[item.id]) ) - : [desc(stageDocumentsView.createdAt)] + : [desc(stageDocumentsView.createdAt)] // 트랜잭션 실행 @@ -1195,10 +1161,10 @@ export async function sendDocumentsToSHI(contractId: number) { try { const api = new ShiBuyerSystemAPI() const result = await api.sendToSHI(contractId) - + // 캐시 무효화 revalidatePath(`/partners/document-list-only/${contractId}`) - + return result } catch (error) { console.error("SHI 전송 실패:", error) @@ -1215,10 +1181,10 @@ export async function pullDocumentStatusFromSHI( try { const api = new ShiBuyerSystemAPI() const result = await api.pullDocumentStatus(contractId) - + // 캐시 무효화 revalidatePath(`/partners/document-list-only/${contractId}`) - + return result } catch (error) { console.error("문서 상태 풀링 실패:", error) @@ -1254,10 +1220,10 @@ export async function validateFiles(files: FileValidation[]): Promise<Validation if (!session?.user?.companyId) { throw new Error("Unauthorized") } - + const vendorId = session.user.companyId const results: ValidationResult[] = [] - + for (const file of files) { // stageSubmissionView에서 매칭되는 레코드 찾기 const match = await db @@ -1277,7 +1243,7 @@ export async function validateFiles(files: FileValidation[]): Promise<Validation ) ) .limit(1) - + if (match.length > 0) { results.push({ projectId: file.projectId, @@ -1298,6 +1264,238 @@ export async function validateFiles(files: FileValidation[]): Promise<Validation }) } } - + return results -}
\ No newline at end of file +} + + + +// ============================================================================= +// Type Definitions (서버와 클라이언트 공유) +// ============================================================================= +export interface ParsedDocument { + docNumber: string + title: string + documentClass: string + vendorDocNumber?: string + notes?: string +} + +export interface ParsedStage { + docNumber: string + stageName: string + planDate?: string +} + +interface UploadData { + contractId: number + documents: ParsedDocument[] + stages: ParsedStage[] + projectType: "ship" | "plant" +} + +// ============================================================================= +// Upload Import Data (서버 액션) +// ============================================================================= +export async function uploadImportData(data: UploadData) { + const { contractId, documents, stages, projectType } = data + const warnings: string[] = [] + const createdDocumentIds: number[] = [] + const documentIdMap = new Map<string, number>() // docNumber -> documentId + + try { + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!contract ) { + throw new Error("Contract not found") + } + + + // 2. Document Class 매핑 가져오기 (트랜잭션 밖에서) + const documentClassesData = await db + .select({ + id: documentClasses.id, + value: documentClasses.value, + description: documentClasses.description, + }) + .from(documentClasses) + .where(and(eq(documentClasses.projectId, contract.projectId), eq(documentClasses.isActive, true))) + + const classMap = new Map( + documentClassesData.map(dc => [dc.value, dc.id]) + ) + + console.log(classMap) + + // 3. 각 문서를 개별적으로 처리 (개별 트랜잭션) + for (const doc of documents) { + console.log(doc) + const documentClassId = classMap.get(doc.documentClass) + + if (!documentClassId) { + warnings.push(`Document Class "${doc.documentClass}"를 찾을 수 없습니다 (문서: ${doc.docNumber})`) + continue + } + + try { + // 개별 트랜잭션으로 각 문서 처리 + const result = await db.transaction(async (tx) => { + // 먼저 문서가 이미 존재하는지 확인 + const [existingDoc] = await tx + .select({ id: stageDocuments.id }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.projectId, contract.projectId), + eq(stageDocuments.docNumber, doc.docNumber), + eq(stageDocuments.status, "ACTIVE") + ) + ) + .limit(1) + + if (existingDoc) { + throw new Error(`문서번호 "${doc.docNumber}"가 이미 존재합니다`) + } + + // 3-1. 문서 생성 + const [newDoc] = await tx + .insert(stageDocuments) + .values({ + docNumber: doc.docNumber, + title: doc.title, + vendorDocNumber: doc.vendorDocNumber || null, + projectId:contract.projectId, + vendorId:contract.vendorId, + contractId, + status: "ACTIVE", + syncStatus: "pending", + syncVersion: 0, + }) + .returning({ id: stageDocuments.id }) + + if (!newDoc) { + throw new Error(`문서 생성 실패: ${doc.docNumber}`) + } + + // 3-2. Document Class Options에서 스테이지 자동 생성 + const classOptions = await db + .select() + .from(documentClassOptions) + .where( + and( + eq(documentClassOptions.documentClassId, documentClassId), + eq(documentClassOptions.isActive, true) + ) + ) + .orderBy(asc(documentClassOptions.sdq)) + + + // 3-3. 각 옵션에 대해 스테이지 생성 + const stageInserts = [] + + console.log(documentClassId, "documentClassId") + console.log(classOptions, "classOptions") + + for (const option of classOptions) { + // stages 배열에서 해당 스테이지의 plan date 찾기 + const stageData = stages.find( + s => s.docNumber === doc.docNumber && s.stageName === option.description + ) + + stageInserts.push({ + documentId: newDoc.id, + stageName: option.description, + stageOrder: option.sdq, + stageStatus: "PLANNED" as const, + priority: "MEDIUM" as const, + planDate: stageData?.planDate || null, + reminderDays: 3, + }) + } + + // 모든 스테이지를 한번에 삽입 + if (stageInserts.length > 0) { + await tx.insert(stageIssueStages).values(stageInserts) + } + + return newDoc.id + }) + + createdDocumentIds.push(result) + documentIdMap.set(doc.docNumber, result) + + } catch (error) { + console.error(`Error creating document ${doc.docNumber}:`, error) + warnings.push(`문서 생성 중 오류: ${doc.docNumber} - ${error instanceof Error ? error.message : 'Unknown error'}`) + } + } + + // 4. 기존 문서의 Plan Date 업데이트 (신규 생성된 문서는 제외) + const processedDocNumbers = new Set(documents.map(d => d.docNumber)) + + for (const stage of stages) { + if (!stage.planDate) continue + + // 이미 처리된 신규 문서는 제외 + if (processedDocNumbers.has(stage.docNumber)) continue + + try { + // 기존 문서 찾기 + const [existingDoc] = await db + .select({ id: stageDocuments.id }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.projectId, contract.projectId), + eq(stageDocuments.docNumber, stage.docNumber), + eq(stageDocuments.status, "ACTIVE") + ) + ) + .limit(1) + + if (!existingDoc) { + warnings.push(`스테이지 업데이트 실패: 문서 "${stage.docNumber}"를 찾을 수 없습니다`) + continue + } + + // 스테이지 plan date 업데이트 + await db + .update(stageIssueStages) + .set({ + planDate: stage.planDate, + updatedAt: new Date() + }) + .where( + and( + eq(stageIssueStages.documentId, existingDoc.id), + eq(stageIssueStages.stageName, stage.stageName) + ) + ) + } catch (error) { + console.error(`Error updating stage for document ${stage.docNumber}:`, error) + warnings.push(`스테이지 "${stage.stageName}" 업데이트 실패 (문서: ${stage.docNumber})`) + } + } + + return { + success: true, + data: { + success: true, + createdCount: createdDocumentIds.length, + documentIds: createdDocumentIds + }, + warnings, + message: `${createdDocumentIds.length}개 문서가 성공적으로 생성되었습니다` + } + + } catch (error) { + console.error("Upload import data error:", error) + return { + success: false, + error: error instanceof Error ? error.message : "데이터 업로드 중 오류가 발생했습니다", + warnings + } + } +} diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 50d54a92..6cc112e3 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -14,11 +14,11 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { - AlertTriangle, - Clock, - TrendingUp, - Target, - Users, + FileText, + Send, + Search, + CheckCircle2, + XCircle, Plus, FileSpreadsheet } from "lucide-react" @@ -32,7 +32,6 @@ import { DocumentStagesExpandedContent } from "./document-stages-expanded-conten import { AddDocumentDialog, DeleteDocumentsDialog } from "./document-stage-dialogs" import { EditDocumentDialog } from "./document-stage-dialogs" import { EditStageDialog } from "./document-stage-dialogs" -import { ExcelImportDialog } from "./document-stage-dialogs" import { DocumentsTableToolbarActions } from "./document-stage-toolbar" import { useSession } from "next-auth/react" @@ -51,7 +50,6 @@ export function DocumentStagesTable({ const { data: session } = useSession() - // URL에서 언어 파라미터 가져오기 const params = useParams() const lng = (params?.lng as string) || 'ko' @@ -63,7 +61,7 @@ export function DocumentStagesTable({ // 상태 관리 const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageDocumentsView> | null>(null) const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) - const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') + const [quickFilter, setQuickFilter] = React.useState<'all' | 'submitted' | 'under_review' | 'approved' | 'rejected'>('all') // 다이얼로그 상태들 const [addDocumentOpen, setAddDocumentOpen] = React.useState(false) @@ -112,52 +110,41 @@ export function DocumentStagesTable({ [expandedRows, projectType, currentDomain] ) - // 통계 계산 + // 문서 상태별 통계 계산 const stats = React.useMemo(() => { - console.log('DocumentStagesTable - data:', data) - console.log('DocumentStagesTable - data length:', data?.length) - const totalDocs = data?.length || 0 - const overdue = data?.filter(doc => doc.isOverdue)?.length || 0 - const dueSoon = data?.filter(doc => - doc.daysUntilDue !== null && - doc.daysUntilDue >= 0 && - doc.daysUntilDue <= 3 + const submitted = data?.filter(doc => doc.status === 'SUBMITTED')?.length || 0 + const underReview = data?.filter(doc => doc.status === 'UNDER_REVIEW')?.length || 0 + const approved = data?.filter(doc => doc.status === 'APPROVED')?.length || 0 + const rejected = data?.filter(doc => doc.status === 'REJECTED')?.length || 0 + const notSubmitted = data?.filter(doc => + !doc.status || !['SUBMITTED', 'UNDER_REVIEW', 'APPROVED', 'REJECTED'].includes(doc.status) )?.length || 0 - const inProgress = data?.filter(doc => doc.currentStageStatus === 'IN_PROGRESS')?.length || 0 - const highPriority = data?.filter(doc => doc.currentStagePriority === 'HIGH')?.length || 0 - const avgProgress = totalDocs > 0 - ? Math.round((data?.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) || 0) / totalDocs) - : 0 - const result = { + return { total: totalDocs, - overdue, - dueSoon, - inProgress, - highPriority, - avgProgress + submitted, + underReview, + approved, + rejected, + notSubmitted, + approvalRate: totalDocs > 0 + ? Math.round((approved / totalDocs) * 100) + : 0 } - - console.log('DocumentStagesTable - stats:', result) - return result }, [data]) // 빠른 필터링 const filteredData = React.useMemo(() => { switch (quickFilter) { - case 'overdue': - return data.filter(doc => doc.isOverdue) - case 'due_soon': - return data.filter(doc => - doc.daysUntilDue !== null && - doc.daysUntilDue >= 0 && - doc.daysUntilDue <= 3 - ) - case 'in_progress': - return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') - case 'high_priority': - return data.filter(doc => doc.currentStagePriority === 'HIGH') + case 'submitted': + return data.filter(doc => doc.status === 'SUBMITTED') + case 'under_review': + return data.filter(doc => doc.status === 'UNDER_REVIEW') + case 'approved': + return data.filter(doc => doc.status === 'APPROVED') + case 'rejected': + return data.filter(doc => doc.status === 'REJECTED') default: return data } @@ -172,24 +159,6 @@ export function DocumentStagesTable({ setExcelImportOpen(true) } - const handleBulkAction = async (action: string, selectedRows: any[]) => { - try { - if (action === 'bulk_complete') { - const stageIds = selectedRows - .map(row => row.original.currentStageId) - .filter(Boolean) - - if (stageIds.length > 0) { - toast.success(t('documentList.messages.stageCompletionSuccess', { count: stageIds.length })) - } - } else if (action === 'bulk_assign') { - toast.info(t('documentList.messages.bulkAssignPending')) - } - } catch (error) { - toast.error(t('documentList.messages.bulkActionError')) - } - } - const closeAllDialogs = () => { setAddDocumentOpen(false) setEditDocumentOpen(false) @@ -201,8 +170,7 @@ export function DocumentStagesTable({ } // 필터 필드 정의 - const filterFields: DataTableFilterField<StageDocumentsView>[] = [ - ] + const filterFields: DataTableFilterField<StageDocumentsView>[] = [] const advancedFilterFields: DataTableAdvancedFilterField<StageDocumentsView>[] = [ { @@ -216,37 +184,18 @@ export function DocumentStagesTable({ type: "text", }, { - id: "currentStageStatus", - label: "스테이지 상태", + id: "status", + label: "문서 상태", type: "select", options: [ - { label: "계획됨", value: "PLANNED" }, - { label: "진행중", value: "IN_PROGRESS" }, { label: "제출됨", value: "SUBMITTED" }, - { label: "완료됨", value: "COMPLETED" }, - ], - }, - { - id: "currentStagePriority", - label: "우선순위", - type: "select", - options: [ - { label: "높음", value: "HIGH" }, - { label: "보통", value: "MEDIUM" }, - { label: "낮음", value: "LOW" }, - ], - }, - { - id: "isOverdue", - label: "지연 여부", - type: "select", - options: [ - { label: "지연됨", value: "true" }, - { label: "정상", value: "false" }, + { label: "검토중", value: "UNDER_REVIEW" }, + { label: "승인됨", value: "APPROVED" }, + { label: "반려됨", value: "REJECTED" }, ], }, { - id: "currentStageAssigneeName", + id: "pic", label: "담당자", type: "text", }, @@ -276,95 +225,111 @@ export function DocumentStagesTable({ return ( <div className="space-y-6"> - {/* 통계 대시보드 */} + {/* 문서 상태 대시보드 */} <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + {/* 전체 문서 */} <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">{t('documentList.dashboard.totalDocuments')}</CardTitle> - <TrendingUp className="h-4 w-4 text-muted-foreground" /> + <CardTitle className="text-sm font-medium">Total Documents</CardTitle> + <FileText className="h-4 w-4 text-muted-foreground" /> </CardHeader> <CardContent> <div className="text-2xl font-bold">{stats.total}</div> <p className="text-xs text-muted-foreground"> - {t('documentList.dashboard.totalDocumentCount', { total: stats.total })} + 전체 등록 문서 </p> </CardContent> </Card> - <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}> + {/* 제출됨 */} + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('submitted')}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">{t('documentList.dashboard.overdueDocuments')}</CardTitle> - <AlertTriangle className="h-4 w-4 text-red-500" /> + <CardTitle className="text-sm font-medium">Submitted</CardTitle> + <Send className="h-4 w-4 text-blue-500" /> </CardHeader> <CardContent> - <div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.overdue}</div> - <p className="text-xs text-muted-foreground">{t('documentList.dashboard.checkImmediately')}</p> + <div className="text-2xl font-bold text-blue-600 dark:text-blue-400">{stats.submitted}</div> + <p className="text-xs text-muted-foreground">제출 대기중</p> </CardContent> </Card> - <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}> + {/* 검토중 */} + {/* <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('under_review')}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">{t('documentList.dashboard.dueSoonDocuments')}</CardTitle> - <Clock className="h-4 w-4 text-orange-500" /> + <CardTitle className="text-sm font-medium">Under Review</CardTitle> + <Search className="h-4 w-4 text-orange-500" /> </CardHeader> <CardContent> - <div className="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.dueSoon}</div> - <p className="text-xs text-muted-foreground">{t('documentList.dashboard.dueInDays')}</p> + <div className="text-2xl font-bold text-orange-600 dark:text-orange-400">{stats.underReview}</div> + <p className="text-xs text-muted-foreground">검토 진행중</p> + </CardContent> + </Card> */} + + {/* 승인됨 */} + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('approved')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">Approved</CardTitle> + <CheckCircle2 className="h-4 w-4 text-green-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.approved}</div> + <p className="text-xs text-muted-foreground">승인 완료 ({stats.approvalRate}%)</p> </CardContent> </Card> - <Card> + <Card className="cursor-pointer hover:shadow-md transition-shadow border-red-200 dark:border-red-800" + onClick={() => setQuickFilter('rejected')}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">{t('documentList.dashboard.averageProgress')}</CardTitle> - <Target className="h-4 w-4 text-green-500" /> + <CardTitle className="text-sm font-medium">Rejected</CardTitle> + <XCircle className="h-4 w-4 text-red-500" /> </CardHeader> <CardContent> - <div className="text-2xl font-bold text-green-600 dark:text-green-400">{stats.avgProgress}%</div> - <p className="text-xs text-muted-foreground">{t('documentList.dashboard.overallProgress')}</p> + <div className="text-2xl font-bold text-red-600 dark:text-red-400">{stats.rejected}</div> + <p className="text-xs text-muted-foreground">재작업 필요</p> </CardContent> </Card> </div> - {/* 빠른 필터 */} + {/* 빠른 필터 뱃지 */} <div className="flex gap-2 overflow-x-auto pb-2"> <Badge variant={quickFilter === 'all' ? 'default' : 'outline'} className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" onClick={() => setQuickFilter('all')} > - {t('documentList.quickFilters.all')} ({stats.total}) + 전체 ({stats.total}) </Badge> <Badge - variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} - className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" - onClick={() => setQuickFilter('overdue')} + variant={quickFilter === 'submitted' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 whitespace-nowrap" + onClick={() => setQuickFilter('submitted')} > - <AlertTriangle className="w-3 h-3 mr-1" /> - {t('documentList.quickFilters.overdue')} ({stats.overdue}) + <Send className="w-3 h-3 mr-1" /> + 제출됨 ({stats.submitted}) </Badge> <Badge - variant={quickFilter === 'due_soon' ? 'default' : 'outline'} + variant={quickFilter === 'under_review' ? 'default' : 'outline'} className="cursor-pointer hover:bg-orange-500 hover:text-white dark:hover:bg-orange-600 whitespace-nowrap" - onClick={() => setQuickFilter('due_soon')} + onClick={() => setQuickFilter('under_review')} > - <Clock className="w-3 h-3 mr-1" /> - {t('documentList.quickFilters.dueSoon')} ({stats.dueSoon}) + <Search className="w-3 h-3 mr-1" /> + 검토중 ({stats.underReview}) </Badge> <Badge - variant={quickFilter === 'in_progress' ? 'default' : 'outline'} - className="cursor-pointer hover:bg-blue-500 hover:text-white dark:hover:bg-blue-600 whitespace-nowrap" - onClick={() => setQuickFilter('in_progress')} + variant={quickFilter === 'approved' ? 'success' : 'outline'} + className="cursor-pointer hover:bg-green-500 hover:text-white dark:hover:bg-green-600 whitespace-nowrap" + onClick={() => setQuickFilter('approved')} > - <Users className="w-3 h-3 mr-1" /> - {t('documentList.quickFilters.inProgress')} ({stats.inProgress}) + <CheckCircle2 className="w-3 h-3 mr-1" /> + 승인됨 ({stats.approved}) </Badge> <Badge - variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} + variant={quickFilter === 'rejected' ? 'destructive' : 'outline'} className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" - onClick={() => setQuickFilter('high_priority')} + onClick={() => setQuickFilter('rejected')} > - <Target className="w-3 h-3 mr-1" /> - {t('documentList.quickFilters.highPriority')} ({stats.highPriority}) + <XCircle className="w-3 h-3 mr-1" /> + 반려됨 ({stats.rejected}) </Badge> </div> @@ -375,6 +340,7 @@ export function DocumentStagesTable({ table={table} expandable={true} expandedRows={expandedRows} + simpleExpansion={true} setExpandedRows={setExpandedRows} renderExpandedContent={(document) => ( <DocumentStagesExpandedContent @@ -440,25 +406,13 @@ export function DocumentStagesTable({ stageId={selectedStageId} /> - <ExcelImportDialog - open={excelImportOpen} - onOpenChange={(open) => { - if (!open) closeAllDialogs() - else setExcelImportOpen(open) - }} - contractId={contractId} - projectType={projectType} - /> - <DeleteDocumentsDialog open={rowAction?.type === "delete"} onOpenChange={() => setRowAction(null)} showTrigger={false} - documents={rowAction?.row.original ? [rowAction?.row.original] : []} // 전체 문서 배열 + documents={rowAction?.row.original ? [rowAction?.row.original] : []} onSuccess={() => rowAction?.row.toggleSelected(false)} /> - - </div> ) }
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts deleted file mode 100644 index c1409205..00000000 --- a/lib/vendor-document-list/plant/excel-import-export.ts +++ /dev/null @@ -1,788 +0,0 @@ -// excel-import-export.ts -"use client" - -import ExcelJS from 'exceljs' -import { - excelDocumentRowSchema, - excelStageRowSchema, - type ExcelDocumentRow, - type ExcelStageRow, - type ExcelImportResult, - type CreateDocumentInput -} from './document-stage-validations' -import { StageDocumentsView } from '@/db/schema' - -// ============================================================================= -// 1. 엑셀 템플릿 생성 및 다운로드 -// ============================================================================= - -// 문서 템플릿 생성 -export async function createDocumentTemplate(projectType: "ship" | "plant") { - const workbook = new ExcelJS.Workbook() - const worksheet = workbook.addWorksheet("문서목록", { - properties: { defaultColWidth: 15 } - }) - - const baseHeaders = [ - "문서번호*", - "문서명*", - "문서종류*", - "PIC", - "발행일", - "설명" - ] - - const plantHeaders = [ - "벤더문서번호", - "벤더명", - "벤더코드" - ] - - const b4Headers = [ - "C구분", - "D구분", - "Degree구분", - "부서구분", - "S구분", - "J구분" - ] - - const headers = [ - ...baseHeaders, - ...(projectType === "plant" ? plantHeaders : []), - ...b4Headers - ] - - // 헤더 행 추가 및 스타일링 - const headerRow = worksheet.addRow(headers) - headerRow.eachCell((cell, colNumber) => { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FF4472C4' } - } - cell.font = { - color: { argb: 'FFFFFFFF' }, - bold: true, - size: 11 - } - cell.alignment = { - horizontal: 'center', - vertical: 'middle' - } - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - // 필수 필드 표시 - if (cell.value && String(cell.value).includes('*')) { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE74C3C' } - } - } - }) - - // 샘플 데이터 추가 - const sampleData = projectType === "ship" ? [ - "SH-2024-001", - "기본 설계 도면", - "B3", - "김철수", - new Date("2024-01-15"), - "선박 기본 설계 관련 문서", - "", "", "", "", "", "" // B4 필드들 - ] : [ - "PL-2024-001", - "공정 설계 도면", - "B4", - "이영희", - new Date("2024-01-15"), - "플랜트 공정 설계 관련 문서", - "V-001", // 벤더문서번호 - "삼성엔지니어링", // 벤더명 - "SENG", // 벤더코드 - "C1", "D1", "DEG1", "DEPT1", "S1", "J1" // B4 필드들 - ] - - const sampleRow = worksheet.addRow(sampleData) - sampleRow.eachCell((cell, colNumber) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - // 날짜 형식 설정 - if (cell.value instanceof Date) { - cell.numFmt = 'yyyy-mm-dd' - } - }) - - // 컬럼 너비 자동 조정 - worksheet.columns.forEach((column, index) => { - if (index < 6) { - column.width = headers[index].length + 5 - } else { - column.width = 12 - } - }) - - // 문서종류 드롭다운 설정 - const docTypeCol = headers.indexOf("문서종류*") + 1 - worksheet.dataValidations.add(`${String.fromCharCode(64 + docTypeCol)}2:${String.fromCharCode(64 + docTypeCol)}1000`, { - type: 'list', - allowBlank: false, - formulae: ['"B3,B4,B5"'] - }) - - // Plant 프로젝트의 경우 우선순위 드롭다운 추가 - if (projectType === "plant") { - // 여기에 추가적인 드롭다운들을 설정할 수 있습니다 - } - - return workbook -} - -// 스테이지 템플릿 생성 -export async function createStageTemplate() { - const workbook = new ExcelJS.Workbook() - const worksheet = workbook.addWorksheet("스테이지목록", { - properties: { defaultColWidth: 15 } - }) - - const headers = [ - "문서번호*", - "스테이지명*", - "계획일", - "우선순위", - "담당자", - "설명", - "스테이지순서" - ] - - // 헤더 행 추가 및 스타일링 - const headerRow = worksheet.addRow(headers) - headerRow.eachCell((cell, colNumber) => { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FF27AE60' } - } - cell.font = { - color: { argb: 'FFFFFFFF' }, - bold: true, - size: 11 - } - cell.alignment = { - horizontal: 'center', - vertical: 'middle' - } - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - // 필수 필드 표시 - if (cell.value && String(cell.value).includes('*')) { - cell.fill = { - type: 'pattern', - pattern: 'solid', - fgColor: { argb: 'FFE74C3C' } - } - } - }) - - // 샘플 데이터 추가 - const sampleData = [ - [ - "SH-2024-001", - "초기 설계 검토", - new Date("2024-02-15"), - "HIGH", - "김철수", - "초기 설계안 검토 및 승인", - 0 - ], - [ - "SH-2024-001", - "상세 설계", - new Date("2024-03-15"), - "MEDIUM", - "이영희", - "상세 설계 작업 수행", - 1 - ] - ] - - sampleData.forEach(rowData => { - const row = worksheet.addRow(rowData) - row.eachCell((cell, colNumber) => { - cell.border = { - top: { style: 'thin' }, - left: { style: 'thin' }, - bottom: { style: 'thin' }, - right: { style: 'thin' } - } - - // 날짜 형식 설정 - if (cell.value instanceof Date) { - cell.numFmt = 'yyyy-mm-dd' - } - }) - }) - - // 컬럼 너비 설정 - worksheet.columns = [ - { width: 15 }, // 문서번호 - { width: 20 }, // 스테이지명 - { width: 12 }, // 계획일 - { width: 10 }, // 우선순위 - { width: 15 }, // 담당자 - { width: 30 }, // 설명 - { width: 12 }, // 스테이지순서 - ] - - // 우선순위 드롭다운 설정 - worksheet.dataValidations.add('D2:D1000', { - type: 'list', - allowBlank: true, - formulae: ['"HIGH,MEDIUM,LOW"'] - }) - - return workbook -} - -// 템플릿 다운로드 함수 -export async function downloadTemplate(type: "documents" | "stages", projectType: "ship" | "plant") { - const workbook = await (type === "documents" - ? createDocumentTemplate(projectType) - : createStageTemplate()) - - const filename = type === "documents" - ? `문서_임포트_템플릿_${projectType}.xlsx` - : `스테이지_임포트_템플릿.xlsx` - - // 브라우저에서 다운로드 - const buffer = await workbook.xlsx.writeBuffer() - const blob = new Blob([buffer], { - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' - }) - - const url = window.URL.createObjectURL(blob) - const link = document.createElement('a') - link.href = url - link.download = filename - link.click() - - // 메모리 정리 - window.URL.revokeObjectURL(url) -} - -// ============================================================================= -// 2. 엑셀 파일 읽기 및 파싱 -// ============================================================================= - -// 엑셀 파일을 읽어서 JSON으로 변환 -export async function readExcelFile(file: File): Promise<any[]> { - return new Promise((resolve, reject) => { - const reader = new FileReader() - - reader.onload = async (e) => { - try { - const buffer = e.target?.result as ArrayBuffer - const workbook = new ExcelJS.Workbook() - await workbook.xlsx.load(buffer) - - const worksheet = workbook.getWorksheet(1) // 첫 번째 워크시트 - if (!worksheet) { - throw new Error('워크시트를 찾을 수 없습니다') - } - - const jsonData: any[] = [] - - worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { - const rowData: any[] = [] - row.eachCell({ includeEmpty: true }, (cell, colNumber) => { - let value = cell.value - - // 날짜 처리 - if (cell.type === ExcelJS.ValueType.Date) { - value = cell.value as Date - } - // 수식 결과값 처리 - else if (cell.type === ExcelJS.ValueType.Formula && cell.result) { - value = cell.result - } - // 하이퍼링크 처리 - else if (cell.type === ExcelJS.ValueType.Hyperlink) { - value = cell.value?.text || cell.value - } - - rowData[colNumber - 1] = value || "" - }) - - jsonData.push(rowData) - }) - - resolve(jsonData) - } catch (error) { - reject(new Error('엑셀 파일을 읽는 중 오류가 발생했습니다: ' + error)) - } - } - - reader.onerror = () => { - reject(new Error('파일을 읽을 수 없습니다')) - } - - reader.readAsArrayBuffer(file) - }) -} - -// 문서 데이터 유효성 검사 및 변환 -export function validateDocumentRows( - rawData: any[], - contractId: number, - projectType: "ship" | "plant" -): { validData: CreateDocumentInput[], errors: any[] } { - if (rawData.length < 2) { - throw new Error('데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다.') - } - - const headers = rawData[0] as string[] - const rows = rawData.slice(1) - - const validData: CreateDocumentInput[] = [] - const errors: any[] = [] - - // 필수 헤더 검사 - const requiredHeaders = ["문서번호", "문서명", "문서종류"] - const missingHeaders = requiredHeaders.filter(h => - !headers.some(header => header.includes(h.replace("*", ""))) - ) - - if (missingHeaders.length > 0) { - throw new Error(`필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`) - } - - // 헤더 인덱스 매핑 - const headerMap: Record<string, number> = {} - headers.forEach((header, index) => { - const cleanHeader = header.replace("*", "").trim() - headerMap[cleanHeader] = index - }) - - // 각 행 처리 - rows.forEach((row: any[], rowIndex) => { - try { - // 빈 행 스킵 - if (row.every(cell => !cell || String(cell).trim() === "")) { - return - } - - const rowData: any = { - contractId, - docNumber: String(row[headerMap["문서번호"]] || "").trim(), - title: String(row[headerMap["문서명"]] || "").trim(), - drawingKind: String(row[headerMap["문서종류"]] || "").trim(), - pic: String(row[headerMap["PIC"]] || "").trim() || undefined, - issuedDate: row[headerMap["발행일"]] ? - formatExcelDate(row[headerMap["발행일"]]) : undefined, - } - - // Plant 프로젝트 전용 필드 - if (projectType === "plant") { - rowData.vendorDocNumber = String(row[headerMap["벤더문서번호"]] || "").trim() || undefined - } - - // B4 전용 필드들 - const b4Fields = ["C구분", "D구분", "Degree구분", "부서구분", "S구분", "J구분"] - const b4FieldMap = { - "C구분": "cGbn", - "D구분": "dGbn", - "Degree구분": "degreeGbn", - "부서구분": "deptGbn", - "S구분": "sGbn", - "J구분": "jGbn" - } - - b4Fields.forEach(field => { - if (headerMap[field] !== undefined) { - const value = String(row[headerMap[field]] || "").trim() - if (value) { - rowData[b4FieldMap[field as keyof typeof b4FieldMap]] = value - } - } - }) - - // 유효성 검사 - const validatedData = excelDocumentRowSchema.parse({ - "문서번호": rowData.docNumber, - "문서명": rowData.title, - "문서종류": rowData.drawingKind, - "벤더문서번호": rowData.vendorDocNumber, - "PIC": rowData.pic, - "발행일": rowData.issuedDate, - "C구분": rowData.cGbn, - "D구분": rowData.dGbn, - "Degree구분": rowData.degreeGbn, - "부서구분": rowData.deptGbn, - "S구분": rowData.sGbn, - "J구분": rowData.jGbn, - }) - - // CreateDocumentInput 형태로 변환 - const documentInput: CreateDocumentInput = { - contractId, - docNumber: validatedData["문서번호"], - title: validatedData["문서명"], - drawingKind: validatedData["문서종류"], - vendorDocNumber: validatedData["벤더문서번호"], - pic: validatedData["PIC"], - issuedDate: validatedData["발행일"], - cGbn: validatedData["C구분"], - dGbn: validatedData["D구분"], - degreeGbn: validatedData["Degree구분"], - deptGbn: validatedData["부서구분"], - sGbn: validatedData["S구분"], - jGbn: validatedData["J구분"], - } - - validData.push(documentInput) - - } catch (error) { - errors.push({ - row: rowIndex + 2, // 엑셀 행 번호 (헤더 포함) - message: error instanceof Error ? error.message : "알 수 없는 오류", - data: row - }) - } - }) - - return { validData, errors } -} - -// 엑셀 날짜 형식 변환 -function formatExcelDate(value: any): string | undefined { - if (!value) return undefined - - // ExcelJS에서 Date 객체로 처리된 경우 - if (value instanceof Date) { - return value.toISOString().split('T')[0] - } - - // 이미 문자열 날짜 형식인 경우 - if (typeof value === 'string') { - const dateMatch = value.match(/^\d{4}-\d{2}-\d{2}$/) - if (dateMatch) return value - - // 다른 형식 시도 - const date = new Date(value) - if (!isNaN(date.getTime())) { - return date.toISOString().split('T')[0] - } - } - - // 엑셀 시리얼 날짜인 경우 - if (typeof value === 'number') { - // ExcelJS는 이미 Date 객체로 변환해주므로 이 경우는 드물지만 - // 1900년 1월 1일부터의 일수로 계산 - const excelEpoch = new Date(1900, 0, 1) - const date = new Date(excelEpoch.getTime() + (value - 2) * 24 * 60 * 60 * 1000) - if (!isNaN(date.getTime())) { - return date.toISOString().split('T')[0] - } - } - - return undefined -} - -// ============================================================================= -// 3. 데이터 익스포트 -// ============================================================================= - -// 문서 데이터를 엑셀로 익스포트 -export function exportDocumentsToExcel( - documents: StageDocumentsView[], - projectType: "ship" | "plant" -) { - const headers = [ - "문서번호", - "문서명", - "문서종류", - "PIC", - "발행일", - "현재스테이지", - "스테이지상태", - "계획일", - "담당자", - "우선순위", - "진행률(%)", - "완료스테이지", - "전체스테이지", - "지연여부", - "남은일수", - "생성일", - "수정일" - ] - - // Plant 프로젝트 전용 헤더 추가 - if (projectType === "plant") { - headers.splice(3, 0, "벤더문서번호", "벤더명", "벤더코드") - } - - const data = documents.map(doc => { - const baseData = [ - doc.docNumber, - doc.title, - doc.drawingKind || "", - doc.pic || "", - doc.issuedDate || "", - doc.currentStageName || "", - getStatusText(doc.currentStageStatus || ""), - doc.currentStagePlanDate || "", - doc.currentStageAssigneeName || "", - getPriorityText(doc.currentStagePriority || ""), - doc.progressPercentage || 0, - doc.completedStages || 0, - doc.totalStages || 0, - doc.isOverdue ? "예" : "아니오", - doc.daysUntilDue || "", - doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : "", - doc.updatedAt ? new Date(doc.updatedAt).toLocaleDateString() : "" - ] - - // Plant 프로젝트 데이터 추가 - if (projectType === "plant") { - baseData.splice(3, 0, - doc.vendorDocNumber || "", - doc.vendorName || "", - doc.vendorCode || "" - ) - } - - return baseData - }) - - const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) - - // 컬럼 너비 설정 - const colWidths = [ - { wch: 15 }, // 문서번호 - { wch: 30 }, // 문서명 - { wch: 10 }, // 문서종류 - ...(projectType === "plant" ? [ - { wch: 15 }, // 벤더문서번호 - { wch: 20 }, // 벤더명 - { wch: 10 }, // 벤더코드 - ] : []), - { wch: 10 }, // PIC - { wch: 12 }, // 발행일 - { wch: 15 }, // 현재스테이지 - { wch: 10 }, // 스테이지상태 - { wch: 12 }, // 계획일 - { wch: 10 }, // 담당자 - { wch: 8 }, // 우선순위 - { wch: 8 }, // 진행률 - { wch: 8 }, // 완료스테이지 - { wch: 8 }, // 전체스테이지 - { wch: 8 }, // 지연여부 - { wch: 8 }, // 남은일수 - { wch: 12 }, // 생성일 - { wch: 12 }, // 수정일 - ] - - worksheet['!cols'] = colWidths - - const workbook = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(workbook, worksheet, "문서목록") - - const filename = `문서목록_${new Date().toISOString().split('T')[0]}.xlsx` - XLSX.writeFile(workbook, filename) -} - -// 스테이지 상세 데이터를 엑셀로 익스포트 -export function exportStageDetailsToExcel(documents: StageDocumentsView[]) { - const headers = [ - "문서번호", - "문서명", - "스테이지명", - "스테이지상태", - "스테이지순서", - "계획일", - "담당자", - "우선순위", - "설명", - "노트", - "알림일수" - ] - - const data: any[] = [] - - documents.forEach(doc => { - if (doc.allStages && doc.allStages.length > 0) { - doc.allStages.forEach(stage => { - data.push([ - doc.docNumber, - doc.title, - stage.stageName, - getStatusText(stage.stageStatus), - stage.stageOrder, - stage.planDate || "", - stage.assigneeName || "", - getPriorityText(stage.priority), - stage.description || "", - stage.notes || "", - stage.reminderDays || "" - ]) - }) - } else { - // 스테이지가 없는 문서도 포함 - data.push([ - doc.docNumber, - doc.title, - "", "", "", "", "", "", "", "", "" - ]) - } - }) - - const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) - - // 컬럼 너비 설정 - worksheet['!cols'] = [ - { wch: 15 }, // 문서번호 - { wch: 30 }, // 문서명 - { wch: 20 }, // 스테이지명 - { wch: 12 }, // 스테이지상태 - { wch: 8 }, // 스테이지순서 - { wch: 12 }, // 계획일 - { wch: 10 }, // 담당자 - { wch: 8 }, // 우선순위 - { wch: 25 }, // 설명 - { wch: 25 }, // 노트 - { wch: 8 }, // 알림일수 - ] - - const workbook = XLSX.utils.book_new() - XLSX.utils.book_append_sheet(workbook, worksheet, "스테이지상세") - - const filename = `스테이지상세_${new Date().toISOString().split('T')[0]}.xlsx` - XLSX.writeFile(workbook, filename) -} - -// ============================================================================= -// 4. 유틸리티 함수들 -// ============================================================================= - -function getStatusText(status: string): string { - switch (status) { - case 'PLANNED': return '계획됨' - case 'IN_PROGRESS': return '진행중' - case 'SUBMITTED': return '제출됨' - case 'UNDER_REVIEW': return '검토중' - case 'APPROVED': return '승인됨' - case 'REJECTED': return '반려됨' - case 'COMPLETED': return '완료됨' - default: return status - } -} - -function getPriorityText(priority: string): string { - switch (priority) { - case 'HIGH': return '높음' - case 'MEDIUM': return '보통' - case 'LOW': return '낮음' - default: return priority - } -} - -// 파일 크기 검증 -export function validateFileSize(file: File, maxSizeMB: number = 10): boolean { - const maxSizeBytes = maxSizeMB * 1024 * 1024 - return file.size <= maxSizeBytes -} - -// 파일 확장자 검증 -export function validateFileExtension(file: File): boolean { - const allowedExtensions = ['.xlsx', '.xls'] - const fileName = file.name.toLowerCase() - return allowedExtensions.some(ext => fileName.endsWith(ext)) -} - -// ExcelJS 워크북의 유효성 검사 -export async function validateExcelWorkbook(file: File): Promise<{ - isValid: boolean - error?: string - worksheetCount?: number - firstWorksheetName?: string -}> { - try { - const buffer = await file.arrayBuffer() - const workbook = new ExcelJS.Workbook() - await workbook.xlsx.load(buffer) - - const worksheets = workbook.worksheets - if (worksheets.length === 0) { - return { - isValid: false, - error: '워크시트가 없는 파일입니다' - } - } - - const firstWorksheet = worksheets[0] - if (firstWorksheet.rowCount < 2) { - return { - isValid: false, - error: '데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다' - } - } - - return { - isValid: true, - worksheetCount: worksheets.length, - firstWorksheetName: firstWorksheet.name - } - } catch (error) { - return { - isValid: false, - error: `파일을 읽을 수 없습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}` - } - } -} - -// 셀 값을 안전하게 문자열로 변환 -export function getCellValueAsString(cell: ExcelJS.Cell): string { - if (!cell.value) return "" - - if (cell.value instanceof Date) { - return cell.value.toISOString().split('T')[0] - } - - if (typeof cell.value === 'object' && 'text' in cell.value) { - return cell.value.text || "" - } - - if (typeof cell.value === 'object' && 'result' in cell.value) { - return String(cell.value.result || "") - } - - return String(cell.value) -} - -// 엑셀 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...) -export function getExcelColumnName(index: number): string { - let result = "" - while (index > 0) { - index-- - result = String.fromCharCode(65 + (index % 26)) + result - index = Math.floor(index / 26) - } - return result -}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx b/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx new file mode 100644 index 00000000..8dc85c51 --- /dev/null +++ b/lib/vendor-document-list/plant/excel-import-stage copy 2.tsx @@ -0,0 +1,899 @@ +"use client" + +import React from "react" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Upload, FileSpreadsheet, Loader2, Download, CheckCircle, AlertCircle } from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import ExcelJS from "exceljs" +import { + getDocumentClassOptionsByContract, + uploadImportData, +} from "./document-stages-service" + +// ============================================================================= +// Type Definitions +// ============================================================================= +interface ExcelImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +interface ImportResult { + documents: any[] + stages: any[] + errors: string[] + warnings: string[] +} + +interface ParsedDocument { + docNumber: string + title: string + documentClass: string + vendorDocNumber?: string + notes?: string + stages?: { stageName: string; planDate: string }[] +} + +// ============================================================================= +// Main Component +// ============================================================================= +export function ExcelImportDialog({ + open, + onOpenChange, + contractId, + projectType +}: ExcelImportDialogProps) { + const [file, setFile] = React.useState<File | null>(null) + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) + const [importResult, setImportResult] = React.useState<ImportResult | null>(null) + const [processStep, setProcessStep] = React.useState<string>("") + const [progress, setProgress] = React.useState(0) + const router = useRouter() + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (selectedFile) { + if (!validateFileExtension(selectedFile)) { + toast.error("Excel 파일(.xlsx, .xls)만 업로드 가능합니다.") + return + } + if (!validateFileSize(selectedFile, 10)) { + toast.error("파일 크기는 10MB 이하여야 합니다.") + return + } + setFile(selectedFile) + setImportResult(null) + } + } + + const handleDownloadTemplate = async () => { + setIsDownloadingTemplate(true) + try { + const workbook = await createImportTemplate(projectType, contractId) + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `문서_임포트_템플릿_${projectType}_${new Date().toISOString().split("T")[0]}.xlsx` + link.click() + window.URL.revokeObjectURL(url) + toast.success("템플릿 파일이 다운로드되었습니다.") + } catch (error) { + toast.error("템플릿 다운로드 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : "알 수 없는 오류")) + } finally { + setIsDownloadingTemplate(false) + } + } + + const handleImport = async () => { + if (!file) { + toast.error("파일을 선택해주세요.") + return + } + setIsProcessing(true) + setProgress(0) + try { + setProcessStep("파일 읽는 중...") + setProgress(20) + const workbook = new ExcelJS.Workbook() + const buffer = await file.arrayBuffer() + await workbook.xlsx.load(buffer) + + setProcessStep("데이터 검증 중...") + setProgress(40) + const worksheet = workbook.getWorksheet("Documents") || workbook.getWorksheet(1) + if (!worksheet) throw new Error("Documents 시트를 찾을 수 없습니다.") + + setProcessStep("문서 및 스테이지 데이터 파싱 중...") + setProgress(60) + const parseResult = await parseDocumentsWithStages(worksheet, projectType, contractId) + + setProcessStep("서버에 업로드 중...") + setProgress(90) + const allStages: any[] = [] + parseResult.validData.forEach((doc) => { + if (doc.stages) { + doc.stages.forEach((stage) => { + allStages.push({ + docNumber: doc.docNumber, + stageName: stage.stageName, + planDate: stage.planDate, + }) + }) + } + }) + + const result = await uploadImportData({ + contractId, + documents: parseResult.validData, + stages: allStages, + projectType, + }) + + if (result.success) { + setImportResult({ + documents: parseResult.validData, + stages: allStages, + errors: parseResult.errors, + warnings: result.warnings || [], + }) + setProgress(100) + toast.success(`${parseResult.validData.length}개 문서가 성공적으로 임포트되었습니다.`) + } else { + throw new Error(result.error || "임포트에 실패했습니다.") + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "임포트 중 오류가 발생했습니다.") + setImportResult({ + documents: [], + stages: [], + errors: [error instanceof Error ? error.message : "알 수 없는 오류"], + warnings: [], + }) + } finally { + setIsProcessing(false) + setProcessStep("") + setProgress(0) + } + } + + const handleClose = () => { + setFile(null) + setImportResult(null) + setProgress(0) + setProcessStep("") + onOpenChange(false) + } + + const handleConfirmImport = () => { + router.refresh() + handleClose() + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle> + <FileSpreadsheet className="inline w-5 h-5 mr-2" /> + Excel 파일 임포트 + </DialogTitle> + <DialogDescription> + Excel 파일을 사용하여 문서와 스테이지 계획을 일괄 등록합니다. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* 템플릿 다운로드 섹션 */} + <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> + <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. 템플릿 다운로드</h4> + <p className="text-sm text-blue-700 dark:text-blue-300 mb-3"> + 올바른 형식과 스마트 검증이 적용된 템플릿을 다운로드하세요. + </p> + <Button variant="outline" size="sm" onClick={handleDownloadTemplate} disabled={isDownloadingTemplate}> + {isDownloadingTemplate ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Download className="h-4 w-4 mr-2" />} + 템플릿 다운로드 + </Button> + </div> + + {/* 파일 업로드 섹션 */} + <div className="border rounded-lg p-4"> + <h4 className="font-medium mb-2">2. 파일 업로드</h4> + <div className="grid gap-2"> + <Label htmlFor="excel-file">Excel 파일 선택</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isProcessing} + className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" + /> + {file && ( + <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> + 선택된 파일: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + </div> + + {/* 진행 상태 */} + {isProcessing && ( + <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30"> + <div className="flex items-center gap-2 mb-2"> + <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> + <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">처리 중...</span> + </div> + <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p> + <Progress value={progress} className="h-2" /> + </div> + )} + + {/* 임포트 결과 */} + {importResult && <ImportResultDisplay importResult={importResult} />} + + {/* 파일 형식 가이드 */} + <FileFormatGuide projectType={projectType} /> + </div> + </div> + + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={handleClose}> + {importResult ? "닫기" : "취소"} + </Button> + {!importResult ? ( + <Button onClick={handleImport} disabled={!file || isProcessing}> + {isProcessing ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />} + {isProcessing ? "처리 중..." : "임포트 시작"} + </Button> + ) : importResult.documents.length > 0 ? ( + <Button onClick={handleConfirmImport}>완료 및 새로고침</Button> + ) : null} + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +// ============================================================================= +// Sub Components +// ============================================================================= +function ImportResultDisplay({ importResult }: { importResult: ImportResult }) { + return ( + <div className="space-y-3"> + {importResult.documents.length > 0 && ( + <Alert> + <CheckCircle className="h-4 w-4" /> + <AlertDescription> + <strong>{importResult.documents.length}개</strong> 문서가 성공적으로 임포트되었습니다. + {importResult.stages.length > 0 && <> ({importResult.stages.length}개 스테이지 계획날짜 포함)</>} + </AlertDescription> + </Alert> + )} + + {importResult.warnings.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>경고:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.warnings.map((warning, index) => ( + <li key={index} className="text-sm"> + {warning} + </li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {importResult.errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>오류:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.errors.map((error, index) => ( + <li key={index} className="text-sm"> + {error} + </li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + </div> + ) +} + +function FileFormatGuide({ projectType }: { projectType: "ship" | "plant" }) { + return ( + <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> + <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">파일 형식 가이드</h4> + <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1"> + <p> + <strong>통합 Documents 시트:</strong> + </p> + <ul className="ml-4 list-disc"> + <li>Document Number* (문서번호)</li> + <li>Document Name* (문서명)</li> + <li>Document Class* (문서클래스 - 드롭다운 선택)</li> + <li>Project Doc No.* (프로젝트 문서번호)</li> + <li>각 Stage Name 컬럼 (계획날짜 입력: YYYY-MM-DD)</li> + </ul> + <p className="mt-2 text-green-600 dark:text-green-400"> + <strong>스마트 검증 기능:</strong> + </p> + <ul className="ml-4 list-disc text-green-600 dark:text-green-400"> + <li>Document Class 드롭다운으로 정확한 값 선택</li> + <li>선택한 Class에 맞지 않는 Stage는 자동으로 회색 처리</li> + <li>잘못된 Stage에 날짜 입력시 빨간색으로 경고</li> + <li>날짜 형식 자동 검증</li> + </ul> + <p className="mt-2 text-yellow-600 dark:text-yellow-400"> + <strong>색상 가이드:</strong> + </p> + <ul className="ml-4 list-disc text-yellow-600 dark:text-yellow-400"> + <li>🟦 파란색 헤더: 필수 입력 항목</li> + <li>🟩 초록색 헤더: 해당 Class의 유효한 Stage</li> + <li>⬜ 회색 셀: 해당 Class에서 사용 불가능한 Stage</li> + <li>🟥 빨간색 셀: 잘못된 입력 (검증 실패)</li> + </ul> + <p className="mt-2 text-red-600 dark:text-red-400">* 필수 항목</p> + </div> + </div> + ) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= +function validateFileExtension(file: File): boolean { + const allowedExtensions = [".xlsx", ".xls"] + const fileName = file.name.toLowerCase() + return allowedExtensions.some((ext) => fileName.endsWith(ext)) +} + +function validateFileSize(file: File, maxSizeMB: number): boolean { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes +} + +function getExcelColumnName(index: number): string { + let result = "" + while (index > 0) { + index-- + result = String.fromCharCode(65 + (index % 26)) + result + index = Math.floor(index / 26) + } + return result +} + +function styleHeaderRow( + headerRow: ExcelJS.Row, + bgColor: string = "FF4472C4", + startCol?: number, + endCol?: number +) { + const start = startCol || 1 + const end = endCol || headerRow.cellCount + + for (let i = start; i <= end; i++) { + const cell = headerRow.getCell(i) + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + } + cell.font = { + color: { argb: "FFFFFFFF" }, + bold: true, + } + cell.alignment = { + horizontal: "center", + vertical: "middle", + } + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + } + } + headerRow.height = 20 +} + +// ============================================================================= +// Template Creation - 통합 시트 + 조건부서식/검증 +// ============================================================================= +async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) throw new Error(res.error || "데이터 로딩 실패") + + const documentClasses = res.data.classes as Array<{ id: number; code: string; description: string }> + const options = res.data.options as Array<{ documentClassId: number; optionValue: string }> + + // 클래스별 옵션 맵 + const optionsByClassId = new Map<number, string[]>() + for (const c of documentClasses) optionsByClassId.set(c.id, []) + for (const o of options) optionsByClassId.get(o.documentClassId)!.push(o.optionValue) + + // 유니크 Stage + const allStageNames = Array.from(new Set(options.map((o) => o.optionValue))) + + const workbook = new ExcelJS.Workbook() + // 파일 열 때 강제 전체 계산 + workbook.calcProperties.fullCalcOnLoad = true + + // ================= Documents 시트 ================= + const worksheet = workbook.addWorksheet("Documents") + + const headers = [ + "Document Number*", + "Document Name*", + "Document Class*", + ...(projectType === "plant" ? ["Project Doc No.*"] : []), + ...allStageNames, + ] + const headerRow = worksheet.addRow(headers) + + // 필수 헤더 (파랑) + const requiredCols = projectType === "plant" ? 4 : 3 + styleHeaderRow(headerRow, "FF4472C4", 1, requiredCols) + // Stage 헤더 (초록) + styleHeaderRow(headerRow, "FF27AE60", requiredCols + 1, headers.length) + + // 샘플 데이터 + const firstClass = documentClasses[0] + const firstClassStages = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : [] + const sampleRow = [ + projectType === "ship" ? "SH-2024-001" : "PL-2024-001", + "샘플 문서명", + firstClass ? firstClass.description : "", + ...(projectType === "plant" ? ["V-001"] : []), + ...allStageNames.map((s) => (firstClassStages.includes(s) ? "2024-03-01" : "")), + ] + worksheet.addRow(sampleRow) + + const docNumberColIndex = 1; // A: Document Number* + const docNameColIndex = 2; // B: Document Name* + const docNumberColLetter = getExcelColumnName(docNumberColIndex); + const docNameColLetter = getExcelColumnName(docNameColIndex); + + worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Document Number는 필수 항목입니다.", + }); + + // 1) 빈값 금지 (길이 > 0) + worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Document Number는 필수 항목입니다.", + }); + + + // 드롭다운: Document Class + const docClassColIndex = 3 // "Document Class*"는 항상 3열 + const docClassColLetter = getExcelColumnName(docClassColIndex) + worksheet.dataValidations.add(`${docClassColLetter}2:${docClassColLetter}1000`, { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$A$2:$A${documentClasses.length + 1}`], + showErrorMessage: true, + errorTitle: "잘못된 입력", + error: "드롭다운 목록에서 Document Class를 선택하세요.", + }) + + // 2) 중복 금지 (COUNTIF로 현재 값이 범위에서 1회만 등장해야 함) + // - Validation은 한 셀에 1개만 가능하므로, 중복 검증은 "Custom" 하나로 통합하는 방법도 있음. + // - 여기서는 '중복 금지'를 추가적으로 **Guidance용**으로 Conditional Formatting(빨간색)으로 가시화합니다. + worksheet.addConditionalFormatting({ + ref: `${docNumberColLetter}2:${docNumberColLetter}1000`, + rules: [ + // 빈값 빨간 + { + type: "expression", + formulae: [`LEN(${docNumberColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + // 중복 빨간: COUNTIF($A$2:$A$1000, A2) > 1 + { + type: "expression", + formulae: [`COUNTIF($${docNumberColLetter}$2:$${docNumberColLetter}$1000,${docNumberColLetter}2)>1`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], + }); + + + // ===== Document Name* (B열): 빈값 금지 + 빈칸 빨간 ===== +worksheet.dataValidations.add(`${docNameColLetter}2:${docNameColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Document Name은 필수 항목입니다.", +}); + +worksheet.addConditionalFormatting({ + ref: `${docNameColLetter}2:${docNameColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${docNameColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], +}); + +// ===== Document Class* (C열): 드롭다운 + allowBlank:false로 차단은 되어 있음 → 빈칸 빨간만 추가 ===== +worksheet.addConditionalFormatting({ + ref: `${docClassColLetter}2:${docClassColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${docClassColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], +}); + +// ===== Project Doc No.* (Plant 전용): (이미 작성하신 코드 유지) ===== +if (projectType === "plant") { + const vendorDocColIndex = 4; // D + const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); + + worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Project Doc No.는 필수 항목입니다.", + }); + + worksheet.addConditionalFormatting({ + ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${vendorDocColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + // 중복 빨간: COUNTIF($A$2:$A$1000, A2) > 1 + { + type: "expression", + formulae: [`COUNTIF($${vendorDocColLetter}$2:$${vendorDocColLetter}$1000,${vendorDocColLetter}2)>1`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], + }); + +} + + if (projectType === "plant") { + const vendorDocColIndex = 4; // Document Number, Name, Class 다음이 Project Doc No.* + const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); + + // 공백 불가: 글자수 > 0 + worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Project Doc No.는 필수 항목입니다.", + }); + + // UX: 비어있으면 빨간 배경으로 표시 (조건부 서식) + worksheet.addConditionalFormatting({ + ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${vendorDocColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, // 연한 빨강 + }, + }, + ], + }); + } + + // 날짜 셀 형식 + 검증/조건부서식 + const stageStartCol = requiredCols + 1 + const stageEndCol = stageStartCol + allStageNames.length - 1 + + // ================= 매트릭스 시트 (Class-Stage Matrix) ================= + const matrixSheet = workbook.addWorksheet("Class-Stage Matrix") + const matrixHeaders = ["Document Class", ...allStageNames] + const matrixHeaderRow = matrixSheet.addRow(matrixHeaders) + styleHeaderRow(matrixHeaderRow, "FF34495E") + for (const docClass of documentClasses) { + const validStages = new Set(optionsByClassId.get(docClass.id) ?? []) + const row = [docClass.description, ...allStageNames.map((stage) => (validStages.has(stage) ? "✓" : ""))] + const dataRow = matrixSheet.addRow(row) + allStageNames.forEach((stage, idx) => { + const cell = dataRow.getCell(idx + 2) + if (validStages.has(stage)) { + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFD4EDDA" } } + cell.font = { color: { argb: "FF28A745" } } + } + }) + } + matrixSheet.columns = [{ width: 30 }, ...allStageNames.map(() => ({ width: 15 }))] + + // 매트릭스 범위 계산 (B ~ 마지막 Stage 열) + const matrixStageFirstColLetter = "B" + const matrixStageLastColLetter = getExcelColumnName(1 + allStageNames.length) // 1:A, 2:B ... (A는 Class, B부터 Stage) + const matrixClassCol = "$A:$A" + const matrixHeaderRowRange = "$1:$1" + const matrixBodyRange = `$${matrixStageFirstColLetter}:$${matrixStageLastColLetter}` + + // ================= 가이드 시트 ================= + const guideSheet = workbook.addWorksheet("사용 가이드") + const guideContent: string[][] = [ + ["📋 통합 문서 임포트 가이드"], + [""], + ["1. 하나의 시트에서 모든 정보 관리"], + [" • Document Number*: 고유한 문서 번호"], + [" • Document Name*: 문서명"], + [" • Document Class*: 드롭다운에서 선택"], + ...(projectType === "plant" ? [[" • Project Doc No.: 벤더 문서 번호"]] : []), + [" • Stage 컬럼들: 각 스테이지의 계획 날짜 (YYYY-MM-DD)"], + [""], + ["2. 스마트 검증 기능"], + [" • Document Class를 선택하면 해당하지 않는 Stage는 자동으로 비활성화(회색)"], + [" • 비유효 Stage에 날짜 입력 시 입력 자체가 막히고 경고 표시"], + [" • 날짜 형식 자동 검증"], + [""], + ["3. Class-Stage Matrix 시트 활용"], + [" • 각 Document Class별로 사용 가능한 Stage 확인"], + [" • ✓ 표시가 있는 Stage만 해당 Class에서 사용 가능"], + [""], + ["4. 작성 순서"], + [" ① Document Number, Name 입력"], + [" ② Document Class 드롭다운에서 선택"], + [" ③ Class-Stage Matrix 확인하여 유효한 Stage 파악"], + [" ④ 해당 Stage 컬럼에만 날짜 입력"], + [""], + ["5. 주의사항"], + [" • * 표시는 필수 항목"], + [" • Document Number는 중복 불가"], + [" • 해당 Class에 맞지 않는 Stage에 날짜 입력 시 무시/차단"], + [" • 날짜는 YYYY-MM-DD 형식 준수"], + ] + guideContent.forEach((row, i) => { + const r = guideSheet.addRow(row) + if (i === 0) r.getCell(1).font = { bold: true, size: 14 } + else if (row[0] && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true } + }) + guideSheet.getColumn(1).width = 70 + + // ================= ReferenceData (숨김) ================= + const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }) + referenceSheet.getCell("A1").value = "DocumentClasses" + documentClasses.forEach((dc, idx) => { + referenceSheet.getCell(`A${idx + 2}`).value = dc.description + }) + + // ================= Stage 열별 서식/검증 ================= + // 문서 시트 컬럼 너비 + worksheet.columns = [ + { width: 18 }, // Doc Number + { width: 30 }, // Doc Name + { width: 30 }, // Doc Class + ...(projectType === "plant" ? [{ width: 18 }] : []), + ...allStageNames.map(() => ({ width: 12 })), + ] + + // 각 Stage 열 처리 + for (let stageIdx = 0; stageIdx < allStageNames.length; stageIdx++) { + const colIndex = stageStartCol + stageIdx + const colLetter = getExcelColumnName(colIndex) + + // 날짜 표시 형식 + for (let row = 2; row <= 1000; row++) { + worksheet.getCell(`${colLetter}${row}`).numFmt = "yyyy-mm-dd" + } + + // ---- 커스텀 데이터 검증 (빈칸 OR (해당 Class에 유효한 Stage AND 숫자(=날짜))) ---- + // INDEX('Class-Stage Matrix'!$B:$ZZ, MATCH($C2,'Class-Stage Matrix'!$A:$A,0), MATCH(H$1,'Class-Stage Matrix'!$1:$1,0)) + const validationFormula = + `=OR(` + + `LEN(${colLetter}2)=0,` + + `AND(` + + `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` + + `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` + + `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` + + `)<>\"\",` + + `ISNUMBER(${colLetter}2)` + + `)` + + `)` + worksheet.dataValidations.add(`${colLetter}2:${colLetter}1000`, { + type: "custom", + allowBlank: true, + formulae: [validationFormula], + showErrorMessage: true, + errorTitle: "허용되지 않은 입력", + error: "이 Stage는 선택한 Document Class에서 사용할 수 없거나 날짜 형식이 아닙니다.", + }) + + // ---- 조건부 서식 (유효하지 않은 Stage → 회색 배경) ---- + // TRUE이면 서식 적용: INDEX(...)="" -> 유효하지 않음 + const cfFormula = + `IFERROR(` + + `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` + + `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` + + `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` + + `)=\"\",` + + `TRUE` + // 매치 실패 등 오류 시에도 회색 처리 + `)` + worksheet.addConditionalFormatting({ + ref: `${colLetter}2:${colLetter}1000`, + rules: [ + { + type: "expression", + formulae: [cfFormula], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFEFEFEF" } }, // 연회색 + }, + }, + ], + }) + } + + return workbook +} + +// ============================================================================= +// Parse Documents with Stages - 통합 파싱 +// ============================================================================= +async function parseDocumentsWithStages( + worksheet: ExcelJS.Worksheet, + projectType: "ship" | "plant", + contractId: number +): Promise<{ validData: ParsedDocument[]; errors: string[] }> { + const documents: ParsedDocument[] = [] + const errors: string[] = [] + const seenDocNumbers = new Set<string>() + + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) { + errors.push("Document Class 정보를 불러올 수 없습니다") + return { validData: [], errors } + } + const documentClasses = res.data.classes as Array<{ id: number; description: string }> + const options = res.data.options as Array<{ documentClassId: number; optionValue: string }> + + // 클래스별 유효한 스테이지 맵 + const validStagesByClass = new Map<string, Set<string>>() + for (const c of documentClasses) { + const stages = options.filter((o) => o.documentClassId === c.id).map((o) => o.optionValue) + validStagesByClass.set(c.description, new Set(stages)) + } + + // 헤더 파싱 + const headerRow = worksheet.getRow(1) + const headers: string[] = [] + headerRow.eachCell((cell, colNumber) => { + headers[colNumber - 1] = String(cell.value || "").trim() + }) + + const docNumberIdx = headers.findIndex((h) => h.includes("Document Number")) + const docNameIdx = headers.findIndex((h) => h.includes("Document Name")) + const docClassIdx = headers.findIndex((h) => h.includes("Document Class")) + const vendorDocNoIdx = projectType === "plant" ? headers.findIndex((h) => h.includes("Project Doc No")) : -1 + + if (docNumberIdx === -1 || docNameIdx === -1 || docClassIdx === -1) { + errors.push("필수 헤더가 누락되었습니다") + return { validData: [], errors } + } + + const stageStartIdx = projectType === "plant" ? 4 : 3 // headers slice 기준(0-index) + const stageHeaders = headers.slice(stageStartIdx) + + // 데이터 행 파싱 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return + const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim() + const docName = String(row.getCell(docNameIdx + 1).value || "").trim() + const docClass = String(row.getCell(docClassIdx + 1).value || "").trim() + const vendorDocNo = vendorDocNoIdx >= 0 ? String(row.getCell(vendorDocNoIdx + 1).value || "").trim() : undefined + + if (!docNumber && !docName) return + if (!docNumber) { + errors.push(`행 ${rowNumber}: Document Number가 없습니다`) + return + } + if (!docName) { + errors.push(`행 ${rowNumber}: Document Name이 없습니다`) + return + } + if (!docClass) { + errors.push(`행 ${rowNumber}: Document Class가 없습니다`) + return + } + if (projectType === "plant" && !vendorDocNo) { + errors.push(`행 ${rowNumber}: Project Doc No.가 없습니다`) + return + } + if (seenDocNumbers.has(docNumber)) { + errors.push(`행 ${rowNumber}: 중복된 Document Number: ${docNumber}`) + return + } + seenDocNumbers.add(docNumber) + + const validStages = validStagesByClass.get(docClass) + if (!validStages) { + errors.push(`행 ${rowNumber}: 유효하지 않은 Document Class: ${docClass}`) + return + } + + + + const stages: { stageName: string; planDate: string }[] = [] + stageHeaders.forEach((stageName, idx) => { + if (validStages.has(stageName)) { + const cell = row.getCell(stageStartIdx + idx + 1) + let planDate = "" + if (cell.value) { + if (cell.value instanceof Date) { + planDate = cell.value.toISOString().split("T")[0] + } else { + const dateStr = String(cell.value).trim() + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) planDate = dateStr + } + if (planDate) stages.push({ stageName, planDate }) + } + } + }) + + documents.push({ + docNumber, + title: docName, + documentClass: docClass, + vendorDocNumber: vendorDocNo, + stages, + }) + }) + + return { validData: documents, errors } +} diff --git a/lib/vendor-document-list/plant/excel-import-stage copy.tsx b/lib/vendor-document-list/plant/excel-import-stage copy.tsx new file mode 100644 index 00000000..068383af --- /dev/null +++ b/lib/vendor-document-list/plant/excel-import-stage copy.tsx @@ -0,0 +1,908 @@ + + +"use client" + +import React from "react" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Upload, FileSpreadsheet, Loader2, Download, CheckCircle, AlertCircle } from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import ExcelJS from 'exceljs' +import { + getDocumentClassOptionsByContract, + // These functions need to be implemented in document-stages-service + uploadImportData, +} from "./document-stages-service" + +// ============================================================================= +// Type Definitions +// ============================================================================= +interface ExcelImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +interface ImportResult { + documents: any[] + stages: any[] + errors: string[] + warnings: string[] +} + +// ============================================================================= +// Main Component +// ============================================================================= +export function ExcelImportDialog({ + open, + onOpenChange, + contractId, + projectType +}: ExcelImportDialogProps) { + const [file, setFile] = React.useState<File | null>(null) + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) + const [importResult, setImportResult] = React.useState<ImportResult | null>(null) + const [processStep, setProcessStep] = React.useState<string>("") + const [progress, setProgress] = React.useState(0) + const router = useRouter() + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (selectedFile) { + // 파일 유효성 검사 + if (!validateFileExtension(selectedFile)) { + toast.error("Excel 파일(.xlsx, .xls)만 업로드 가능합니다.") + return + } + + if (!validateFileSize(selectedFile, 10)) { + toast.error("파일 크기는 10MB 이하여야 합니다.") + return + } + + setFile(selectedFile) + setImportResult(null) + } + } + + const handleDownloadTemplate = async () => { + setIsDownloadingTemplate(true) + try { + const workbook = await createImportTemplate(projectType, contractId) + const buffer = await workbook.xlsx.writeBuffer() + + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `문서_임포트_템플릿_${projectType}_${new Date().toISOString().split('T')[0]}.xlsx` + link.click() + + window.URL.revokeObjectURL(url) + toast.success("템플릿 파일이 다운로드되었습니다.") + } catch (error) { + toast.error("템플릿 다운로드 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : "알 수 없는 오류")) + } finally { + setIsDownloadingTemplate(false) + } + } + + const handleImport = async () => { + if (!file) { + toast.error("파일을 선택해주세요.") + return + } + + setIsProcessing(true) + setProgress(0) + + try { + setProcessStep("파일 읽는 중...") + setProgress(20) + + const workbook = new ExcelJS.Workbook() + const buffer = await file.arrayBuffer() + await workbook.xlsx.load(buffer) + + setProcessStep("데이터 검증 중...") + setProgress(40) + + // 워크시트 확인 + const documentsSheet = workbook.getWorksheet('Documents') || workbook.getWorksheet(1) + const stagesSheet = workbook.getWorksheet('Stage Plan Dates') || workbook.getWorksheet(2) + + if (!documentsSheet) { + throw new Error("Documents 시트를 찾을 수 없습니다.") + } + + setProcessStep("문서 데이터 파싱 중...") + setProgress(60) + + // 문서 데이터 파싱 + const documentData = await parseDocumentsSheet(documentsSheet, projectType) + + setProcessStep("스테이지 데이터 파싱 중...") + setProgress(80) + + // 스테이지 데이터 파싱 (선택사항) + let stageData: any[] = [] + if (stagesSheet) { + stageData = await parseStagesSheet(stagesSheet) + } + + setProcessStep("서버에 업로드 중...") + setProgress(90) + + // 서버로 데이터 전송 + const result = await uploadImportData({ + contractId, + documents: documentData.validData, + stages: stageData, + projectType + }) + + if (result.success) { + setImportResult({ + documents: documentData.validData, + stages: stageData, + errors: documentData.errors, + warnings: result.warnings || [] + }) + setProgress(100) + toast.success(`${documentData.validData.length}개 문서가 성공적으로 임포트되었습니다.`) + } else { + throw new Error(result.error || "임포트에 실패했습니다.") + } + + } catch (error) { + toast.error(error instanceof Error ? error.message : "임포트 중 오류가 발생했습니다.") + setImportResult({ + documents: [], + stages: [], + errors: [error instanceof Error ? error.message : "알 수 없는 오류"], + warnings: [] + }) + } finally { + setIsProcessing(false) + setProcessStep("") + setProgress(0) + } + } + + const handleClose = () => { + setFile(null) + setImportResult(null) + setProgress(0) + setProcessStep("") + onOpenChange(false) + } + + const handleConfirmImport = () => { + router.refresh() + handleClose() + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle> + <FileSpreadsheet className="inline w-5 h-5 mr-2" /> + Excel 파일 임포트 + </DialogTitle> + <DialogDescription> + Excel 파일을 사용하여 문서를 일괄 등록합니다. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* 템플릿 다운로드 섹션 */} + <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> + <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. 템플릿 다운로드</h4> + <p className="text-sm text-blue-700 dark:text-blue-300 mb-3"> + 올바른 형식과 드롭다운이 적용된 템플릿을 다운로드하세요. + </p> + <Button + variant="outline" + size="sm" + onClick={handleDownloadTemplate} + disabled={isDownloadingTemplate} + > + {isDownloadingTemplate ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : ( + <Download className="h-4 w-4 mr-2" /> + )} + 템플릿 다운로드 + </Button> + </div> + + {/* 파일 업로드 섹션 */} + <div className="border rounded-lg p-4"> + <h4 className="font-medium mb-2">2. 파일 업로드</h4> + <div className="grid gap-2"> + <Label htmlFor="excel-file">Excel 파일 선택</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isProcessing} + className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" + /> + {file && ( + <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> + 선택된 파일: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + </div> + + {/* 진행 상태 */} + {isProcessing && ( + <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30"> + <div className="flex items-center gap-2 mb-2"> + <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> + <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">처리 중...</span> + </div> + <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p> + <Progress value={progress} className="h-2" /> + </div> + )} + + {/* 임포트 결과 */} + {importResult && ( + <ImportResultDisplay importResult={importResult} /> + )} + + {/* 파일 형식 가이드 */} + <FileFormatGuide projectType={projectType} /> + </div> + </div> + + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={handleClose}> + {importResult ? "닫기" : "취소"} + </Button> + {!importResult ? ( + <Button + onClick={handleImport} + disabled={!file || isProcessing} + > + {isProcessing ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : ( + <Upload className="h-4 w-4 mr-2" /> + )} + {isProcessing ? "처리 중..." : "임포트 시작"} + </Button> + ) : importResult.documents.length > 0 ? ( + <Button onClick={handleConfirmImport}> + 완료 및 새로고침 + </Button> + ) : null} + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +// ============================================================================= +// Sub Components +// ============================================================================= +function ImportResultDisplay({ importResult }: { importResult: ImportResult }) { + return ( + <div className="space-y-3"> + {importResult.documents.length > 0 && ( + <Alert> + <CheckCircle className="h-4 w-4" /> + <AlertDescription> + <strong>{importResult.documents.length}개</strong> 문서가 성공적으로 임포트되었습니다. + {importResult.stages.length > 0 && ( + <> ({importResult.stages.length}개 스테이지 계획날짜 포함)</> + )} + </AlertDescription> + </Alert> + )} + + {importResult.warnings.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>경고:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.warnings.map((warning, index) => ( + <li key={index} className="text-sm">{warning}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {importResult.errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>오류:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.errors.map((error, index) => ( + <li key={index} className="text-sm">{error}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + </div> + ) +} + +function FileFormatGuide({ projectType }: { projectType: "ship" | "plant" }) { + return ( + <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> + <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">파일 형식 가이드</h4> + <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1"> + <p><strong>Documents 시트:</strong></p> + <ul className="ml-4 list-disc"> + <li>Document Number* (문서번호)</li> + <li>Document Name* (문서명)</li> + <li>Document Class* (문서클래스 - 드롭다운 선택)</li> + {projectType === "plant" && ( + <li>Project Doc No. (벤더문서번호)</li> + )} + </ul> + <p className="mt-2"><strong>Stage Plan Dates 시트 (선택사항):</strong></p> + <ul className="ml-4 list-disc"> + <li>Document Number* (문서번호)</li> + <li>Stage Name* (스테이지명 - 드롭다운 선택)</li> + <li>Plan Date (계획날짜: YYYY-MM-DD)</li> + </ul> + <p className="mt-2 text-green-600 dark:text-green-400"><strong>스마트 기능:</strong></p> + <ul className="ml-4 list-disc text-green-600 dark:text-green-400"> + <li>Document Class는 드롭다운으로 정확한 값만 선택 가능</li> + <li>Stage Name도 드롭다운으로 오타 방지</li> + <li>"사용 가이드" 시트에서 각 클래스별 사용 가능한 스테이지 확인 가능</li> + </ul> + <p className="mt-2 text-red-600 dark:text-red-400">* 필수 항목</p> + </div> + </div> + ) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= +function validateFileExtension(file: File): boolean { + const allowedExtensions = ['.xlsx', '.xls'] + const fileName = file.name.toLowerCase() + return allowedExtensions.some(ext => fileName.endsWith(ext)) +} + +function validateFileSize(file: File, maxSizeMB: number): boolean { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes +} + +// ExcelJS 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...) +function getExcelColumnName(index: number): string { + let result = "" + while (index > 0) { + index-- + result = String.fromCharCode(65 + (index % 26)) + result + index = Math.floor(index / 26) + } + return result +} + +// 헤더 행 스타일링 함수 +function styleHeaderRow(headerRow: ExcelJS.Row, bgColor: string = 'FF4472C4') { + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: bgColor } + } + cell.font = { + color: { argb: 'FFFFFFFF' }, + bold: true + } + cell.alignment = { + horizontal: 'center', + vertical: 'middle' + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + if (String(cell.value).includes('*')) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE74C3C' } + } + } + }) +} + +// 템플릿 생성 함수 - Stage Plan Dates 부분 수정 +async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) throw new Error(res.error || "데이터 로딩 실패") + + const documentClasses = res.data.classes // [{id, code, description}] + const options = res.data.options // [{documentClassId, optionValue, ...}] + + // 클래스별 옵션 맵 + const optionsByClassId = new Map<number, string[]>() + for (const c of documentClasses) optionsByClassId.set(c.id, []) + for (const o of options) { + optionsByClassId.get(o.documentClassId)?.push(o.optionValue) + } + + // 모든 스테이지 명 (유니크) + const allStageNames = Array.from(new Set(options.map(o => o.optionValue))) + + const workbook = new ExcelJS.Workbook() + + // ================= Documents (첫 번째 시트) ================= + const documentsSheet = workbook.addWorksheet("Documents") + const documentHeaders = [ + "Document Number*", + "Document Name*", + "Document Class*", + ...(projectType === "plant" ? ["Project Doc No."] : []), + "Notes", + ] + const documentHeaderRow = documentsSheet.addRow(documentHeaders) + styleHeaderRow(documentHeaderRow) + + const sampleDocumentData = + projectType === "ship" + ? [ + "SH-2024-001", + "기본 설계 도면", + documentClasses[0] ? `${documentClasses[0].description}` : "", + "참고사항", + ] + : [ + "PL-2024-001", + "공정 설계 도면", + documentClasses[0] ? `${documentClasses[0].description}` : "", + "V-001", + "참고사항", + ] + + documentsSheet.addRow(sampleDocumentData) + + // Document Class 드롭다운 + const docClassColIndex = 3 // C + const docClassCol = getExcelColumnName(docClassColIndex) + documentsSheet.dataValidations.add(`${docClassCol}2:${docClassCol}1000`, { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$A$2:$A${documentClasses.length + 1}`], + }) + + documentsSheet.columns = [ + { width: 15 }, + { width: 25 }, + { width: 28 }, + ...(projectType === "plant" ? [{ width: 18 }] : []), + { width: 24 }, + ] + + // ================= Stage Plan Dates (두 번째 시트) - 수정됨 ================= + const stagesSheet = workbook.addWorksheet("Stage Plan Dates") + + // Document Class Helper 컬럼과 Valid Stage Helper 컬럼 추가 + const stageHeaderRow = stagesSheet.addRow([ + "Document Number*", + "Document Class", // Helper 컬럼 - 자동으로 채워짐 + "Stage Name*", + "Plan Date", + "Valid Stages" // Helper 컬럼 - 유효한 스테이지 목록 + ]) + styleHeaderRow(stageHeaderRow, "FF27AE60") + + const firstClass = documentClasses[0] + const firstClassOpts = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : [] + + // 샘플 데이터 + const sampleStageData = [ + [ + projectType === "ship" ? "SH-2024-001" : "PL-2024-001", + firstClass ? firstClass.description : "", + firstClassOpts[0] ?? "", + "2024-02-15", + firstClassOpts.join(", ") // 유효한 스테이지 목록 + ], + [ + projectType === "ship" ? "SH-2024-001" : "PL-2024-001", + firstClass ? firstClass.description : "", + firstClassOpts[1] ?? "", + "2024-03-01", + firstClassOpts.join(", ") // 유효한 스테이지 목록 + ], + ] + + sampleStageData.forEach(row => { + const r = stagesSheet.addRow(row) + r.getCell(4).numFmt = "yyyy-mm-dd" + }) + + // B열(Document Class)에 VLOOKUP 수식 추가 + for (let i = 3; i <= 1000; i++) { + const cell = stagesSheet.getCell(`B${i}`) + cell.value = { + formula: `IFERROR(VLOOKUP(A${i},Documents!A:C,3,FALSE),"")`, + result: "" + } + } + + + // E열(Valid Stages)에 수식 추가 - Document Class에 해당하는 스테이지 목록 표시 + // MATCH와 OFFSET을 사용한 동적 참조 + + + // Helper 컬럼 숨기기 옵션 (B, E열) + stagesSheet.getColumn(2).hidden = false // Document Class는 보이도록 (확인용) + stagesSheet.getColumn(5).hidden = false // Valid Stages도 보이도록 (가이드용) + + // Helper 컬럼 스타일링 + stagesSheet.getColumn(2).eachCell((cell, rowNumber) => { + if (rowNumber > 1) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF0F0F0' } // 연한 회색 배경 + } + cell.protection = { locked: true } // 편집 방지 + } + }) + + stagesSheet.getColumn(5).eachCell((cell, rowNumber) => { + if (rowNumber > 1) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFFFF0E0' } // 연한 주황색 배경 + } + cell.font = { size: 9, italic: true } + } + }) + + // Stage Name 드롭다운 - 전체 스테이지 목록 사용 (ExcelJS 제약으로 인해) + // 하지만 조건부 서식으로 잘못된 선택 강조 + const allStagesCol = getExcelColumnName(documentClasses.length + 2) + stagesSheet.dataValidations.add("C3:C1000", { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!${allStagesCol}$2:${allStagesCol}${allStageNames.length + 1}`], + promptTitle: "Stage Name 선택", + prompt: "Valid Stages 컬럼을 참고하여 올바른 Stage를 선택하세요", + showErrorMessage: true, + errorTitle: "Stage 선택 확인", + error: "Valid Stages 컬럼에 있는 Stage만 유효합니다" + }) + + // 조건부 서식 추가 - 잘못된 Stage 선택시 빨간색 표시 + for (let i = 3; i <= 100; i++) { + try { + // COUNTIF를 사용하여 선택한 Stage가 Valid Stages에 포함되는지 확인 + const rule = { + type: 'expression', + formulae: [`ISERROR(SEARCH(C${i},E${i}))`], + style: { + fill: { + type: 'pattern', + pattern: 'solid', + bgColor: { argb: 'FFFF0000' } // 빨간색 배경 + }, + font: { + color: { argb: 'FFFFFFFF' } // 흰색 글자 + } + } + } + stagesSheet.addConditionalFormatting({ + ref: `C${i}`, + rules: [rule] + }) + } catch (e) { + console.warn(`Row ${i}: 조건부 서식 추가 실패`) + } + } + + stagesSheet.columns = [ + { width: 15 }, // Document Number + { width: 20 }, // Document Class (Helper) + { width: 30 }, // Stage Name + { width: 12 }, // Plan Date + { width: 50 } // Valid Stages (Helper) + ] + + // ================= 사용 가이드 (세 번째 시트) - 수정됨 ================= + const guideSheet = workbook.addWorksheet("사용 가이드") + const guideContent: (string[])[] = [ + ["문서 임포트 가이드"], + [""], + ["1. Documents 시트"], + [" - Document Number*: 고유한 문서 번호를 입력하세요"], + [" - Document Name*: 문서명을 입력하세요"], + [" - Document Class*: 드롭다운에서 문서 클래스를 선택하세요"], + [" - Project Doc No.: 벤더 문서 번호 (Plant 프로젝트만)"], + [" - Notes: 참고사항"], + [""], + ["2. Stage Plan Dates 시트 (선택사항)"], + [" - Document Number*: Documents 시트의 Document Number와 일치해야 합니다"], + [" - Document Class: Document Number 입력시 자동으로 표시됩니다 (회색 배경)"], + [" - Stage Name*: Valid Stages 컬럼을 참고하여 해당 클래스의 스테이지를 선택하세요"], + [" - Plan Date: 계획 날짜 (YYYY-MM-DD 형식)"], + [" - Valid Stages: 해당 Document Class에서 선택 가능한 스테이지 목록 (주황색 배경)"], + [""], + ["3. Stage Name 선택 방법"], + [" ① Document Number를 먼저 입력합니다"], + [" ② Document Class가 자동으로 표시됩니다"], + [" ③ Valid Stages 컬럼에서 사용 가능한 스테이지를 확인합니다"], + [" ④ Stage Name 드롭다운에서 Valid Stages에 있는 항목만 선택합니다"], + [" ⑤ 잘못된 스테이지 선택시 셀이 빨간색으로 표시됩니다"], + [""], + ["4. 주의사항"], + [" - * 표시는 필수 항목입니다"], + [" - Document Number는 고유해야 합니다"], + [" - Stage Name은 Valid Stages에 표시된 것만 유효합니다"], + [" - 빨간색으로 표시된 Stage Name은 잘못된 선택입니다"], + [" - 날짜는 YYYY-MM-DD 형식으로 입력하세요"], + [""], + ["5. Document Class별 사용 가능한 Stage Names"], + [""], + ] + + for (const c of documentClasses) { + guideContent.push([`${c.code} - ${c.description}:`]) + ;(optionsByClassId.get(c.id) ?? []).forEach(v => guideContent.push([` • ${v}`])) + guideContent.push([""]) + } + + guideContent.forEach((row, i) => { + const r = guideSheet.addRow(row) + if (i === 0) r.getCell(1).font = { bold: true, size: 14 } + else if (row[0] && row[0].includes(":") && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true } + }) + guideSheet.getColumn(1).width = 60 + + // ================= ReferenceData (마지막 시트, hidden) ================= + const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }) + + let joinColStart = /* A=1 기준, 빈 안전한 위치 선택 */ documentClasses.length + 3; +const keyCol = getExcelColumnName(joinColStart); // 예: X +const joinedCol = getExcelColumnName(joinColStart+1); // 예: Y +referenceSheet.getCell(`${keyCol}1`).value = "DocClass"; +referenceSheet.getCell(`${joinedCol}1`).value = "JoinedStages"; + + // A열: DocumentClasses + referenceSheet.getCell("A1").value = "DocumentClasses" + documentClasses.forEach((dc, idx) => { + referenceSheet.getCell(`${keyCol}${idx+2}`).value = dc.description; + const stages = (optionsByClassId.get(dc.id) ?? []).join(", "); + referenceSheet.getCell(`${joinedCol}${idx+2}`).value = stages; + }); + + for (let i = 3; i <= 1000; i++) { + stagesSheet.getCell(`E${i}`).value = { + formula: `IFERROR("유효한 스테이지: "&VLOOKUP(B${i},ReferenceData!$${keyCol}$2:$${joinedCol}$${documentClasses.length+1},2,FALSE),"Document Number를 먼저 입력하세요")`, + result: "" + }; + } + + + // B열부터: 각 클래스의 Stage 옵션 + let currentCol = 2 // B + for (const docClass of documentClasses) { + const colLetter = getExcelColumnName(currentCol) + referenceSheet.getCell(`${colLetter}1`).value = docClass.description + + const list = optionsByClassId.get(docClass.id) ?? [] + list.forEach((v, i) => { + referenceSheet.getCell(`${colLetter}${i + 2}`).value = v + }) + + currentCol++ + } + + // 마지막 열: AllStageNames + referenceSheet.getCell(`${allStagesCol}1`).value = "AllStageNames" + allStageNames.forEach((v, i) => { + referenceSheet.getCell(`${allStagesCol}${i + 2}`).value = v + }) + + return workbook +} + +// ============================================================================= +// Type Definitions +// ============================================================================= +interface ParsedDocument { + docNumber: string + title: string + documentClass: string + vendorDocNumber?: string + notes?: string + } + + interface ParsedStage { + docNumber: string + stageName: string + planDate?: string + } + + interface ParseResult { + validData: ParsedDocument[] + errors: string[] + } + + +// ============================================================================= +// 1. Parse Documents Sheet +// ============================================================================= +export async function parseDocumentsSheet( + worksheet: ExcelJS.Worksheet, + projectType: "ship" | "plant" + ): Promise<ParseResult> { + const documents: ParsedDocument[] = [] + const errors: string[] = [] + const seenDocNumbers = new Set<string>() + + // 헤더 행 확인 + const headerRow = worksheet.getRow(1) + const headers: string[] = [] + headerRow.eachCell((cell, colNumber) => { + headers[colNumber - 1] = String(cell.value || "").trim() + }) + + // 헤더 인덱스 찾기 + const docNumberIdx = headers.findIndex(h => h.includes("Document Number")) + const docNameIdx = headers.findIndex(h => h.includes("Document Name")) + const docClassIdx = headers.findIndex(h => h.includes("Document Class")) + const vendorDocNoIdx = projectType === "plant" + ? headers.findIndex(h => h.includes("Project Doc No")) + : -1 + const notesIdx = headers.findIndex(h => h.includes("Notes")) + + if (docNumberIdx === -1 || docNameIdx === -1 || docClassIdx === -1) { + errors.push("필수 헤더가 누락되었습니다: Document Number, Document Name, Document Class") + return { validData: [], errors } + } + + // 데이터 행 파싱 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return // 헤더 행 스킵 + + const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim() + const docName = String(row.getCell(docNameIdx + 1).value || "").trim() + const docClass = String(row.getCell(docClassIdx + 1).value || "").trim() + const vendorDocNo = vendorDocNoIdx >= 0 + ? String(row.getCell(vendorDocNoIdx + 1).value || "").trim() + : undefined + const notes = notesIdx >= 0 + ? String(row.getCell(notesIdx + 1).value || "").trim() + : undefined + + // 빈 행 스킵 + if (!docNumber && !docName) return + + // 유효성 검사 + if (!docNumber) { + errors.push(`행 ${rowNumber}: Document Number가 없습니다`) + return + } + if (!docName) { + errors.push(`행 ${rowNumber}: Document Name이 없습니다`) + return + } + if (!docClass) { + errors.push(`행 ${rowNumber}: Document Class가 없습니다`) + return + } + + // 중복 체크 + if (seenDocNumbers.has(docNumber)) { + errors.push(`행 ${rowNumber}: 중복된 Document Number: ${docNumber}`) + return + } + seenDocNumbers.add(docNumber) + + documents.push({ + docNumber, + title: docName, + documentClass: docClass, + vendorDocNumber: vendorDocNo || undefined, + notes: notes || undefined + }) + }) + + return { validData: documents, errors } + } + + + +// parseStagesSheet 함수도 수정이 필요합니다 +export async function parseStagesSheet(worksheet: ExcelJS.Worksheet): Promise<ParsedStage[]> { + const stages: ParsedStage[] = [] + + // 헤더 행 확인 + const headerRow = worksheet.getRow(1) + const headers: string[] = [] + headerRow.eachCell((cell, colNumber) => { + headers[colNumber - 1] = String(cell.value || "").trim() + }) + + // 헤더 인덱스 찾기 (Helper 컬럼들 고려) + const docNumberIdx = headers.findIndex(h => h.includes("Document Number")) + // Stage Name 찾기 - "Stage Name*" 또는 "Stage Name"을 찾음 + const stageNameIdx = headers.findIndex(h => h.includes("Stage Name") && !h.includes("Valid")) + const planDateIdx = headers.findIndex(h => h.includes("Plan Date")) + + if (docNumberIdx === -1 || stageNameIdx === -1) { + console.error("Stage Plan Dates 시트에 필수 헤더가 없습니다") + return [] + } + + // 데이터 행 파싱 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return // 헤더 행 스킵 + + const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim() + const stageName = String(row.getCell(stageNameIdx + 1).value || "").trim() + let planDate: string | undefined + + // Plan Date 파싱 + if (planDateIdx >= 0) { + const planDateCell = row.getCell(planDateIdx + 1) + + if (planDateCell.value) { + // Date 객체인 경우 + if (planDateCell.value instanceof Date) { + planDate = planDateCell.value.toISOString().split('T')[0] + } + // 문자열인 경우 + else { + const dateStr = String(planDateCell.value).trim() + // YYYY-MM-DD 형식 검증 + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) { + planDate = dateStr + } + } + } + } + + // 빈 행 스킵 + if (!docNumber && !stageName) return + + // 유효성 검사 + if (!docNumber || !stageName) { + console.warn(`행 ${rowNumber}: Document Number 또는 Stage Name이 누락됨`) + return + } + + stages.push({ + docNumber, + stageName, + planDate + }) + }) + + return stages +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/excel-import-stage.tsx b/lib/vendor-document-list/plant/excel-import-stage.tsx new file mode 100644 index 00000000..8dc85c51 --- /dev/null +++ b/lib/vendor-document-list/plant/excel-import-stage.tsx @@ -0,0 +1,899 @@ +"use client" + +import React from "react" +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { Upload, FileSpreadsheet, Loader2, Download, CheckCircle, AlertCircle } from "lucide-react" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import ExcelJS from "exceljs" +import { + getDocumentClassOptionsByContract, + uploadImportData, +} from "./document-stages-service" + +// ============================================================================= +// Type Definitions +// ============================================================================= +interface ExcelImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +interface ImportResult { + documents: any[] + stages: any[] + errors: string[] + warnings: string[] +} + +interface ParsedDocument { + docNumber: string + title: string + documentClass: string + vendorDocNumber?: string + notes?: string + stages?: { stageName: string; planDate: string }[] +} + +// ============================================================================= +// Main Component +// ============================================================================= +export function ExcelImportDialog({ + open, + onOpenChange, + contractId, + projectType +}: ExcelImportDialogProps) { + const [file, setFile] = React.useState<File | null>(null) + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) + const [importResult, setImportResult] = React.useState<ImportResult | null>(null) + const [processStep, setProcessStep] = React.useState<string>("") + const [progress, setProgress] = React.useState(0) + const router = useRouter() + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (selectedFile) { + if (!validateFileExtension(selectedFile)) { + toast.error("Excel 파일(.xlsx, .xls)만 업로드 가능합니다.") + return + } + if (!validateFileSize(selectedFile, 10)) { + toast.error("파일 크기는 10MB 이하여야 합니다.") + return + } + setFile(selectedFile) + setImportResult(null) + } + } + + const handleDownloadTemplate = async () => { + setIsDownloadingTemplate(true) + try { + const workbook = await createImportTemplate(projectType, contractId) + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = `문서_임포트_템플릿_${projectType}_${new Date().toISOString().split("T")[0]}.xlsx` + link.click() + window.URL.revokeObjectURL(url) + toast.success("템플릿 파일이 다운로드되었습니다.") + } catch (error) { + toast.error("템플릿 다운로드 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : "알 수 없는 오류")) + } finally { + setIsDownloadingTemplate(false) + } + } + + const handleImport = async () => { + if (!file) { + toast.error("파일을 선택해주세요.") + return + } + setIsProcessing(true) + setProgress(0) + try { + setProcessStep("파일 읽는 중...") + setProgress(20) + const workbook = new ExcelJS.Workbook() + const buffer = await file.arrayBuffer() + await workbook.xlsx.load(buffer) + + setProcessStep("데이터 검증 중...") + setProgress(40) + const worksheet = workbook.getWorksheet("Documents") || workbook.getWorksheet(1) + if (!worksheet) throw new Error("Documents 시트를 찾을 수 없습니다.") + + setProcessStep("문서 및 스테이지 데이터 파싱 중...") + setProgress(60) + const parseResult = await parseDocumentsWithStages(worksheet, projectType, contractId) + + setProcessStep("서버에 업로드 중...") + setProgress(90) + const allStages: any[] = [] + parseResult.validData.forEach((doc) => { + if (doc.stages) { + doc.stages.forEach((stage) => { + allStages.push({ + docNumber: doc.docNumber, + stageName: stage.stageName, + planDate: stage.planDate, + }) + }) + } + }) + + const result = await uploadImportData({ + contractId, + documents: parseResult.validData, + stages: allStages, + projectType, + }) + + if (result.success) { + setImportResult({ + documents: parseResult.validData, + stages: allStages, + errors: parseResult.errors, + warnings: result.warnings || [], + }) + setProgress(100) + toast.success(`${parseResult.validData.length}개 문서가 성공적으로 임포트되었습니다.`) + } else { + throw new Error(result.error || "임포트에 실패했습니다.") + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "임포트 중 오류가 발생했습니다.") + setImportResult({ + documents: [], + stages: [], + errors: [error instanceof Error ? error.message : "알 수 없는 오류"], + warnings: [], + }) + } finally { + setIsProcessing(false) + setProcessStep("") + setProgress(0) + } + } + + const handleClose = () => { + setFile(null) + setImportResult(null) + setProgress(0) + setProcessStep("") + onOpenChange(false) + } + + const handleConfirmImport = () => { + router.refresh() + handleClose() + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px] max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle> + <FileSpreadsheet className="inline w-5 h-5 mr-2" /> + Excel 파일 임포트 + </DialogTitle> + <DialogDescription> + Excel 파일을 사용하여 문서와 스테이지 계획을 일괄 등록합니다. + </DialogDescription> + </DialogHeader> + + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* 템플릿 다운로드 섹션 */} + <div className="border rounded-lg p-4 bg-blue-50/30 dark:bg-blue-950/30"> + <h4 className="font-medium text-blue-800 dark:text-blue-200 mb-2">1. 템플릿 다운로드</h4> + <p className="text-sm text-blue-700 dark:text-blue-300 mb-3"> + 올바른 형식과 스마트 검증이 적용된 템플릿을 다운로드하세요. + </p> + <Button variant="outline" size="sm" onClick={handleDownloadTemplate} disabled={isDownloadingTemplate}> + {isDownloadingTemplate ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Download className="h-4 w-4 mr-2" />} + 템플릿 다운로드 + </Button> + </div> + + {/* 파일 업로드 섹션 */} + <div className="border rounded-lg p-4"> + <h4 className="font-medium mb-2">2. 파일 업로드</h4> + <div className="grid gap-2"> + <Label htmlFor="excel-file">Excel 파일 선택</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isProcessing} + className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" + /> + {file && ( + <p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> + 선택된 파일: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + </div> + + {/* 진행 상태 */} + {isProcessing && ( + <div className="border rounded-lg p-4 bg-yellow-50/30 dark:bg-yellow-950/30"> + <div className="flex items-center gap-2 mb-2"> + <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> + <span className="text-sm font-medium text-yellow-800 dark:text-yellow-200">처리 중...</span> + </div> + <p className="text-sm text-yellow-700 dark:text-yellow-300 mb-2">{processStep}</p> + <Progress value={progress} className="h-2" /> + </div> + )} + + {/* 임포트 결과 */} + {importResult && <ImportResultDisplay importResult={importResult} />} + + {/* 파일 형식 가이드 */} + <FileFormatGuide projectType={projectType} /> + </div> + </div> + + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={handleClose}> + {importResult ? "닫기" : "취소"} + </Button> + {!importResult ? ( + <Button onClick={handleImport} disabled={!file || isProcessing}> + {isProcessing ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : <Upload className="h-4 w-4 mr-2" />} + {isProcessing ? "처리 중..." : "임포트 시작"} + </Button> + ) : importResult.documents.length > 0 ? ( + <Button onClick={handleConfirmImport}>완료 및 새로고침</Button> + ) : null} + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +// ============================================================================= +// Sub Components +// ============================================================================= +function ImportResultDisplay({ importResult }: { importResult: ImportResult }) { + return ( + <div className="space-y-3"> + {importResult.documents.length > 0 && ( + <Alert> + <CheckCircle className="h-4 w-4" /> + <AlertDescription> + <strong>{importResult.documents.length}개</strong> 문서가 성공적으로 임포트되었습니다. + {importResult.stages.length > 0 && <> ({importResult.stages.length}개 스테이지 계획날짜 포함)</>} + </AlertDescription> + </Alert> + )} + + {importResult.warnings.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>경고:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.warnings.map((warning, index) => ( + <li key={index} className="text-sm"> + {warning} + </li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {importResult.errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>오류:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.errors.map((error, index) => ( + <li key={index} className="text-sm"> + {error} + </li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + </div> + ) +} + +function FileFormatGuide({ projectType }: { projectType: "ship" | "plant" }) { + return ( + <div className="bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg p-4"> + <h4 className="font-medium text-gray-800 dark:text-gray-200 mb-2">파일 형식 가이드</h4> + <div className="text-sm text-gray-700 dark:text-gray-300 space-y-1"> + <p> + <strong>통합 Documents 시트:</strong> + </p> + <ul className="ml-4 list-disc"> + <li>Document Number* (문서번호)</li> + <li>Document Name* (문서명)</li> + <li>Document Class* (문서클래스 - 드롭다운 선택)</li> + <li>Project Doc No.* (프로젝트 문서번호)</li> + <li>각 Stage Name 컬럼 (계획날짜 입력: YYYY-MM-DD)</li> + </ul> + <p className="mt-2 text-green-600 dark:text-green-400"> + <strong>스마트 검증 기능:</strong> + </p> + <ul className="ml-4 list-disc text-green-600 dark:text-green-400"> + <li>Document Class 드롭다운으로 정확한 값 선택</li> + <li>선택한 Class에 맞지 않는 Stage는 자동으로 회색 처리</li> + <li>잘못된 Stage에 날짜 입력시 빨간색으로 경고</li> + <li>날짜 형식 자동 검증</li> + </ul> + <p className="mt-2 text-yellow-600 dark:text-yellow-400"> + <strong>색상 가이드:</strong> + </p> + <ul className="ml-4 list-disc text-yellow-600 dark:text-yellow-400"> + <li>🟦 파란색 헤더: 필수 입력 항목</li> + <li>🟩 초록색 헤더: 해당 Class의 유효한 Stage</li> + <li>⬜ 회색 셀: 해당 Class에서 사용 불가능한 Stage</li> + <li>🟥 빨간색 셀: 잘못된 입력 (검증 실패)</li> + </ul> + <p className="mt-2 text-red-600 dark:text-red-400">* 필수 항목</p> + </div> + </div> + ) +} + +// ============================================================================= +// Helper Functions +// ============================================================================= +function validateFileExtension(file: File): boolean { + const allowedExtensions = [".xlsx", ".xls"] + const fileName = file.name.toLowerCase() + return allowedExtensions.some((ext) => fileName.endsWith(ext)) +} + +function validateFileSize(file: File, maxSizeMB: number): boolean { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes +} + +function getExcelColumnName(index: number): string { + let result = "" + while (index > 0) { + index-- + result = String.fromCharCode(65 + (index % 26)) + result + index = Math.floor(index / 26) + } + return result +} + +function styleHeaderRow( + headerRow: ExcelJS.Row, + bgColor: string = "FF4472C4", + startCol?: number, + endCol?: number +) { + const start = startCol || 1 + const end = endCol || headerRow.cellCount + + for (let i = start; i <= end; i++) { + const cell = headerRow.getCell(i) + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: bgColor }, + } + cell.font = { + color: { argb: "FFFFFFFF" }, + bold: true, + } + cell.alignment = { + horizontal: "center", + vertical: "middle", + } + cell.border = { + top: { style: "thin" }, + left: { style: "thin" }, + bottom: { style: "thin" }, + right: { style: "thin" }, + } + } + headerRow.height = 20 +} + +// ============================================================================= +// Template Creation - 통합 시트 + 조건부서식/검증 +// ============================================================================= +async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) throw new Error(res.error || "데이터 로딩 실패") + + const documentClasses = res.data.classes as Array<{ id: number; code: string; description: string }> + const options = res.data.options as Array<{ documentClassId: number; optionValue: string }> + + // 클래스별 옵션 맵 + const optionsByClassId = new Map<number, string[]>() + for (const c of documentClasses) optionsByClassId.set(c.id, []) + for (const o of options) optionsByClassId.get(o.documentClassId)!.push(o.optionValue) + + // 유니크 Stage + const allStageNames = Array.from(new Set(options.map((o) => o.optionValue))) + + const workbook = new ExcelJS.Workbook() + // 파일 열 때 강제 전체 계산 + workbook.calcProperties.fullCalcOnLoad = true + + // ================= Documents 시트 ================= + const worksheet = workbook.addWorksheet("Documents") + + const headers = [ + "Document Number*", + "Document Name*", + "Document Class*", + ...(projectType === "plant" ? ["Project Doc No.*"] : []), + ...allStageNames, + ] + const headerRow = worksheet.addRow(headers) + + // 필수 헤더 (파랑) + const requiredCols = projectType === "plant" ? 4 : 3 + styleHeaderRow(headerRow, "FF4472C4", 1, requiredCols) + // Stage 헤더 (초록) + styleHeaderRow(headerRow, "FF27AE60", requiredCols + 1, headers.length) + + // 샘플 데이터 + const firstClass = documentClasses[0] + const firstClassStages = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : [] + const sampleRow = [ + projectType === "ship" ? "SH-2024-001" : "PL-2024-001", + "샘플 문서명", + firstClass ? firstClass.description : "", + ...(projectType === "plant" ? ["V-001"] : []), + ...allStageNames.map((s) => (firstClassStages.includes(s) ? "2024-03-01" : "")), + ] + worksheet.addRow(sampleRow) + + const docNumberColIndex = 1; // A: Document Number* + const docNameColIndex = 2; // B: Document Name* + const docNumberColLetter = getExcelColumnName(docNumberColIndex); + const docNameColLetter = getExcelColumnName(docNameColIndex); + + worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Document Number는 필수 항목입니다.", + }); + + // 1) 빈값 금지 (길이 > 0) + worksheet.dataValidations.add(`${docNumberColLetter}2:${docNumberColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Document Number는 필수 항목입니다.", + }); + + + // 드롭다운: Document Class + const docClassColIndex = 3 // "Document Class*"는 항상 3열 + const docClassColLetter = getExcelColumnName(docClassColIndex) + worksheet.dataValidations.add(`${docClassColLetter}2:${docClassColLetter}1000`, { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$A$2:$A${documentClasses.length + 1}`], + showErrorMessage: true, + errorTitle: "잘못된 입력", + error: "드롭다운 목록에서 Document Class를 선택하세요.", + }) + + // 2) 중복 금지 (COUNTIF로 현재 값이 범위에서 1회만 등장해야 함) + // - Validation은 한 셀에 1개만 가능하므로, 중복 검증은 "Custom" 하나로 통합하는 방법도 있음. + // - 여기서는 '중복 금지'를 추가적으로 **Guidance용**으로 Conditional Formatting(빨간색)으로 가시화합니다. + worksheet.addConditionalFormatting({ + ref: `${docNumberColLetter}2:${docNumberColLetter}1000`, + rules: [ + // 빈값 빨간 + { + type: "expression", + formulae: [`LEN(${docNumberColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + // 중복 빨간: COUNTIF($A$2:$A$1000, A2) > 1 + { + type: "expression", + formulae: [`COUNTIF($${docNumberColLetter}$2:$${docNumberColLetter}$1000,${docNumberColLetter}2)>1`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], + }); + + + // ===== Document Name* (B열): 빈값 금지 + 빈칸 빨간 ===== +worksheet.dataValidations.add(`${docNameColLetter}2:${docNameColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Document Name은 필수 항목입니다.", +}); + +worksheet.addConditionalFormatting({ + ref: `${docNameColLetter}2:${docNameColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${docNameColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], +}); + +// ===== Document Class* (C열): 드롭다운 + allowBlank:false로 차단은 되어 있음 → 빈칸 빨간만 추가 ===== +worksheet.addConditionalFormatting({ + ref: `${docClassColLetter}2:${docClassColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${docClassColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], +}); + +// ===== Project Doc No.* (Plant 전용): (이미 작성하신 코드 유지) ===== +if (projectType === "plant") { + const vendorDocColIndex = 4; // D + const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); + + worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Project Doc No.는 필수 항목입니다.", + }); + + worksheet.addConditionalFormatting({ + ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${vendorDocColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + // 중복 빨간: COUNTIF($A$2:$A$1000, A2) > 1 + { + type: "expression", + formulae: [`COUNTIF($${vendorDocColLetter}$2:$${vendorDocColLetter}$1000,${vendorDocColLetter}2)>1`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, + }, + }, + ], + }); + +} + + if (projectType === "plant") { + const vendorDocColIndex = 4; // Document Number, Name, Class 다음이 Project Doc No.* + const vendorDocColLetter = getExcelColumnName(vendorDocColIndex); + + // 공백 불가: 글자수 > 0 + worksheet.dataValidations.add(`${vendorDocColLetter}2:${vendorDocColLetter}1000`, { + type: "textLength", + operator: "greaterThan", + formulae: [0], + allowBlank: false, + showErrorMessage: true, + errorTitle: "필수 입력", + error: "Project Doc No.는 필수 항목입니다.", + }); + + // UX: 비어있으면 빨간 배경으로 표시 (조건부 서식) + worksheet.addConditionalFormatting({ + ref: `${vendorDocColLetter}2:${vendorDocColLetter}1000`, + rules: [ + { + type: "expression", + formulae: [`LEN(${vendorDocColLetter}2)=0`], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFFFC7CE" } }, // 연한 빨강 + }, + }, + ], + }); + } + + // 날짜 셀 형식 + 검증/조건부서식 + const stageStartCol = requiredCols + 1 + const stageEndCol = stageStartCol + allStageNames.length - 1 + + // ================= 매트릭스 시트 (Class-Stage Matrix) ================= + const matrixSheet = workbook.addWorksheet("Class-Stage Matrix") + const matrixHeaders = ["Document Class", ...allStageNames] + const matrixHeaderRow = matrixSheet.addRow(matrixHeaders) + styleHeaderRow(matrixHeaderRow, "FF34495E") + for (const docClass of documentClasses) { + const validStages = new Set(optionsByClassId.get(docClass.id) ?? []) + const row = [docClass.description, ...allStageNames.map((stage) => (validStages.has(stage) ? "✓" : ""))] + const dataRow = matrixSheet.addRow(row) + allStageNames.forEach((stage, idx) => { + const cell = dataRow.getCell(idx + 2) + if (validStages.has(stage)) { + cell.fill = { type: "pattern", pattern: "solid", fgColor: { argb: "FFD4EDDA" } } + cell.font = { color: { argb: "FF28A745" } } + } + }) + } + matrixSheet.columns = [{ width: 30 }, ...allStageNames.map(() => ({ width: 15 }))] + + // 매트릭스 범위 계산 (B ~ 마지막 Stage 열) + const matrixStageFirstColLetter = "B" + const matrixStageLastColLetter = getExcelColumnName(1 + allStageNames.length) // 1:A, 2:B ... (A는 Class, B부터 Stage) + const matrixClassCol = "$A:$A" + const matrixHeaderRowRange = "$1:$1" + const matrixBodyRange = `$${matrixStageFirstColLetter}:$${matrixStageLastColLetter}` + + // ================= 가이드 시트 ================= + const guideSheet = workbook.addWorksheet("사용 가이드") + const guideContent: string[][] = [ + ["📋 통합 문서 임포트 가이드"], + [""], + ["1. 하나의 시트에서 모든 정보 관리"], + [" • Document Number*: 고유한 문서 번호"], + [" • Document Name*: 문서명"], + [" • Document Class*: 드롭다운에서 선택"], + ...(projectType === "plant" ? [[" • Project Doc No.: 벤더 문서 번호"]] : []), + [" • Stage 컬럼들: 각 스테이지의 계획 날짜 (YYYY-MM-DD)"], + [""], + ["2. 스마트 검증 기능"], + [" • Document Class를 선택하면 해당하지 않는 Stage는 자동으로 비활성화(회색)"], + [" • 비유효 Stage에 날짜 입력 시 입력 자체가 막히고 경고 표시"], + [" • 날짜 형식 자동 검증"], + [""], + ["3. Class-Stage Matrix 시트 활용"], + [" • 각 Document Class별로 사용 가능한 Stage 확인"], + [" • ✓ 표시가 있는 Stage만 해당 Class에서 사용 가능"], + [""], + ["4. 작성 순서"], + [" ① Document Number, Name 입력"], + [" ② Document Class 드롭다운에서 선택"], + [" ③ Class-Stage Matrix 확인하여 유효한 Stage 파악"], + [" ④ 해당 Stage 컬럼에만 날짜 입력"], + [""], + ["5. 주의사항"], + [" • * 표시는 필수 항목"], + [" • Document Number는 중복 불가"], + [" • 해당 Class에 맞지 않는 Stage에 날짜 입력 시 무시/차단"], + [" • 날짜는 YYYY-MM-DD 형식 준수"], + ] + guideContent.forEach((row, i) => { + const r = guideSheet.addRow(row) + if (i === 0) r.getCell(1).font = { bold: true, size: 14 } + else if (row[0] && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true } + }) + guideSheet.getColumn(1).width = 70 + + // ================= ReferenceData (숨김) ================= + const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }) + referenceSheet.getCell("A1").value = "DocumentClasses" + documentClasses.forEach((dc, idx) => { + referenceSheet.getCell(`A${idx + 2}`).value = dc.description + }) + + // ================= Stage 열별 서식/검증 ================= + // 문서 시트 컬럼 너비 + worksheet.columns = [ + { width: 18 }, // Doc Number + { width: 30 }, // Doc Name + { width: 30 }, // Doc Class + ...(projectType === "plant" ? [{ width: 18 }] : []), + ...allStageNames.map(() => ({ width: 12 })), + ] + + // 각 Stage 열 처리 + for (let stageIdx = 0; stageIdx < allStageNames.length; stageIdx++) { + const colIndex = stageStartCol + stageIdx + const colLetter = getExcelColumnName(colIndex) + + // 날짜 표시 형식 + for (let row = 2; row <= 1000; row++) { + worksheet.getCell(`${colLetter}${row}`).numFmt = "yyyy-mm-dd" + } + + // ---- 커스텀 데이터 검증 (빈칸 OR (해당 Class에 유효한 Stage AND 숫자(=날짜))) ---- + // INDEX('Class-Stage Matrix'!$B:$ZZ, MATCH($C2,'Class-Stage Matrix'!$A:$A,0), MATCH(H$1,'Class-Stage Matrix'!$1:$1,0)) + const validationFormula = + `=OR(` + + `LEN(${colLetter}2)=0,` + + `AND(` + + `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` + + `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` + + `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` + + `)<>\"\",` + + `ISNUMBER(${colLetter}2)` + + `)` + + `)` + worksheet.dataValidations.add(`${colLetter}2:${colLetter}1000`, { + type: "custom", + allowBlank: true, + formulae: [validationFormula], + showErrorMessage: true, + errorTitle: "허용되지 않은 입력", + error: "이 Stage는 선택한 Document Class에서 사용할 수 없거나 날짜 형식이 아닙니다.", + }) + + // ---- 조건부 서식 (유효하지 않은 Stage → 회색 배경) ---- + // TRUE이면 서식 적용: INDEX(...)="" -> 유효하지 않음 + const cfFormula = + `IFERROR(` + + `INDEX('Class-Stage Matrix'!$${matrixStageFirstColLetter}:$${matrixStageLastColLetter},` + + `MATCH($${docClassColLetter}2,'Class-Stage Matrix'!${matrixClassCol},0),` + + `MATCH(${colLetter}$1,'Class-Stage Matrix'!${matrixHeaderRowRange},0)` + + `)=\"\",` + + `TRUE` + // 매치 실패 등 오류 시에도 회색 처리 + `)` + worksheet.addConditionalFormatting({ + ref: `${colLetter}2:${colLetter}1000`, + rules: [ + { + type: "expression", + formulae: [cfFormula], + style: { + fill: { type: "pattern", pattern: "solid", bgColor: { argb: "FFEFEFEF" } }, // 연회색 + }, + }, + ], + }) + } + + return workbook +} + +// ============================================================================= +// Parse Documents with Stages - 통합 파싱 +// ============================================================================= +async function parseDocumentsWithStages( + worksheet: ExcelJS.Worksheet, + projectType: "ship" | "plant", + contractId: number +): Promise<{ validData: ParsedDocument[]; errors: string[] }> { + const documents: ParsedDocument[] = [] + const errors: string[] = [] + const seenDocNumbers = new Set<string>() + + const res = await getDocumentClassOptionsByContract(contractId) + if (!res.success) { + errors.push("Document Class 정보를 불러올 수 없습니다") + return { validData: [], errors } + } + const documentClasses = res.data.classes as Array<{ id: number; description: string }> + const options = res.data.options as Array<{ documentClassId: number; optionValue: string }> + + // 클래스별 유효한 스테이지 맵 + const validStagesByClass = new Map<string, Set<string>>() + for (const c of documentClasses) { + const stages = options.filter((o) => o.documentClassId === c.id).map((o) => o.optionValue) + validStagesByClass.set(c.description, new Set(stages)) + } + + // 헤더 파싱 + const headerRow = worksheet.getRow(1) + const headers: string[] = [] + headerRow.eachCell((cell, colNumber) => { + headers[colNumber - 1] = String(cell.value || "").trim() + }) + + const docNumberIdx = headers.findIndex((h) => h.includes("Document Number")) + const docNameIdx = headers.findIndex((h) => h.includes("Document Name")) + const docClassIdx = headers.findIndex((h) => h.includes("Document Class")) + const vendorDocNoIdx = projectType === "plant" ? headers.findIndex((h) => h.includes("Project Doc No")) : -1 + + if (docNumberIdx === -1 || docNameIdx === -1 || docClassIdx === -1) { + errors.push("필수 헤더가 누락되었습니다") + return { validData: [], errors } + } + + const stageStartIdx = projectType === "plant" ? 4 : 3 // headers slice 기준(0-index) + const stageHeaders = headers.slice(stageStartIdx) + + // 데이터 행 파싱 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber === 1) return + const docNumber = String(row.getCell(docNumberIdx + 1).value || "").trim() + const docName = String(row.getCell(docNameIdx + 1).value || "").trim() + const docClass = String(row.getCell(docClassIdx + 1).value || "").trim() + const vendorDocNo = vendorDocNoIdx >= 0 ? String(row.getCell(vendorDocNoIdx + 1).value || "").trim() : undefined + + if (!docNumber && !docName) return + if (!docNumber) { + errors.push(`행 ${rowNumber}: Document Number가 없습니다`) + return + } + if (!docName) { + errors.push(`행 ${rowNumber}: Document Name이 없습니다`) + return + } + if (!docClass) { + errors.push(`행 ${rowNumber}: Document Class가 없습니다`) + return + } + if (projectType === "plant" && !vendorDocNo) { + errors.push(`행 ${rowNumber}: Project Doc No.가 없습니다`) + return + } + if (seenDocNumbers.has(docNumber)) { + errors.push(`행 ${rowNumber}: 중복된 Document Number: ${docNumber}`) + return + } + seenDocNumbers.add(docNumber) + + const validStages = validStagesByClass.get(docClass) + if (!validStages) { + errors.push(`행 ${rowNumber}: 유효하지 않은 Document Class: ${docClass}`) + return + } + + + + const stages: { stageName: string; planDate: string }[] = [] + stageHeaders.forEach((stageName, idx) => { + if (validStages.has(stageName)) { + const cell = row.getCell(stageStartIdx + idx + 1) + let planDate = "" + if (cell.value) { + if (cell.value instanceof Date) { + planDate = cell.value.toISOString().split("T")[0] + } else { + const dateStr = String(cell.value).trim() + if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) planDate = dateStr + } + if (planDate) stages.push({ stageName, planDate }) + } + } + }) + + documents.push({ + docNumber, + title: docName, + documentClass: docClass, + vendorDocNumber: vendorDocNo, + stages, + }) + }) + + return { validData: documents, errors } +} diff --git a/lib/vendor-document-list/plant/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx index c0f17afc..01fc61df 100644 --- a/lib/vendor-document-list/plant/upload/columns.tsx +++ b/lib/vendor-document-list/plant/upload/columns.tsx @@ -25,7 +25,8 @@ import { CheckCircle2, XCircle, AlertCircle, - Clock + Clock, + Download } from "lucide-react" interface GetColumnsProps { @@ -360,6 +361,16 @@ export function getColumns({ </> )} + + {/* ✅ 커버 페이지 다운로드 */} + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "downloadCover" })} + className="gap-2" + > + <Download className="h-4 w-4" /> + Download Cover Page + </DropdownMenuItem> + <DropdownMenuSeparator /> <DropdownMenuItem diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx index 92507900..84b04092 100644 --- a/lib/vendor-document-list/plant/upload/table.tsx +++ b/lib/vendor-document-list/plant/upload/table.tsx @@ -20,6 +20,7 @@ import { ProjectFilter } from "./components/project-filter" import { SingleUploadDialog } from "./components/single-upload-dialog" import { HistoryDialog } from "./components/history-dialog" import { ViewSubmissionDialog } from "./components/view-submission-dialog" +import { toast } from "sonner" interface StageSubmissionsTableProps { promises: Promise<[ @@ -159,6 +160,30 @@ export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubm columnResizeMode: "onEnd", }) + + React.useEffect(() => { + if (!rowAction) return; + + const { type, row } = rowAction; + + if (type === "downloadCover") { + // 2) 서버에서 생성 후 다운로드 (예: API 호출) + (async () => { + try { + const res = await fetch(`/api/stages/${row.original.stageId}/cover`, { method: "POST" }); + if (!res.ok) throw new Error("failed"); + const { fileUrl } = await res.json(); // 서버 응답: { fileUrl: string } + window.open(fileUrl, "_blank", "noopener,noreferrer"); + } catch (e) { + toast.error("커버 페이지 생성에 실패했습니다."); + console.error(e); + } finally { + setRowAction(null); + } + })(); + } + }, [rowAction, setRowAction]); + return ( <> <DataTable table={table}> diff --git a/lib/vendors/contacts-table/add-contact-dialog.tsx b/lib/vendors/contacts-table/add-contact-dialog.tsx index 5376583a..22c557b4 100644 --- a/lib/vendors/contacts-table/add-contact-dialog.tsx +++ b/lib/vendors/contacts-table/add-contact-dialog.tsx @@ -8,6 +8,13 @@ import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, Dialog import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Form, FormControl, FormField, @@ -29,6 +36,20 @@ interface AddContactDialogProps { export function AddContactDialog({ vendorId }: AddContactDialogProps) { const [open, setOpen] = React.useState(false) + // 담당업무 옵션 + const taskOptions = [ + { value: "회사대표", label: "회사대표 President/Director" }, + { value: "영업관리", label: "영업관리 Sales Management" }, + { value: "설계/기술", label: "설계/기술 Engineering/Design" }, + { value: "구매", label: "구매 Procurement" }, + { value: "납기/출하/운송", label: "납기/출하/운송 Delivery Control" }, + { value: "PM/생산관리", label: "PM/생산관리 PM/Manufacturing" }, + { value: "품질관리", label: "품질관리 Quality Management" }, + { value: "세금계산서/납품서관리", label: "세금계산서/납품서관리 Shipping Doc. Management" }, + { value: "A/S 관리", label: "A/S 관리 A/S Management" }, + { value: "FSE", label: "FSE(야드작업자) Field Service Engineer" } + ] + // react-hook-form 세팅 const form = useForm<CreateVendorContactSchema>({ resolver: zodResolver(createVendorContactSchema), @@ -37,6 +58,8 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { vendorId, contactName: "", contactPosition: "", + contactDepartment: "", + contactTask: "", contactEmail: "", contactPhone: "", isPrimary: false, @@ -88,7 +111,7 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { name="contactName" render={({ field }) => ( <FormItem> - <FormLabel>Contact Name</FormLabel> + <FormLabel>담당자명</FormLabel> <FormControl> <Input placeholder="예: 홍길동" {...field} /> </FormControl> @@ -102,7 +125,7 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { name="contactPosition" render={({ field }) => ( <FormItem> - <FormLabel>Position / Title</FormLabel> + <FormLabel>직급</FormLabel> <FormControl> <Input placeholder="예: 과장" {...field} /> </FormControl> @@ -113,12 +136,12 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { <FormField control={form.control} - name="contactEmail" + name="contactDepartment" render={({ field }) => ( <FormItem> - <FormLabel>Email</FormLabel> + <FormLabel>부서</FormLabel> <FormControl> - <Input placeholder="name@company.com" {...field} /> + <Input placeholder="예: 영업부" {...field} /> </FormControl> <FormMessage /> </FormItem> @@ -127,36 +150,58 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) { <FormField control={form.control} - name="contactPhone" + name="contactTask" + render={({ field }) => ( + <FormItem> + <FormLabel>담당업무</FormLabel> + <Select onValueChange={field.onChange} value={field.value || undefined}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="담당업무를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {taskOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactEmail" render={({ field }) => ( <FormItem> - <FormLabel>Phone</FormLabel> + <FormLabel>이메일</FormLabel> <FormControl> - <Input placeholder="010-1234-5678" {...field} /> + <Input placeholder="name@company.com" {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> - {/* 단순 checkbox */} <FormField control={form.control} - name="isPrimary" + name="contactPhone" render={({ field }) => ( <FormItem> - <div className="flex items-center space-x-2 mt-2"> - <input - type="checkbox" - checked={field.value} - onChange={(e) => field.onChange(e.target.checked)} - /> - <FormLabel>Is Primary?</FormLabel> - </div> + <FormLabel>전화번호</FormLabel> + <FormControl> + <Input placeholder="010-1234-5678" {...field} /> + </FormControl> <FormMessage /> </FormItem> )} /> + + </div> <DialogFooter> diff --git a/lib/vendors/contacts-table/contact-table.tsx b/lib/vendors/contacts-table/contact-table.tsx index 2991187e..65b12451 100644 --- a/lib/vendors/contacts-table/contact-table.tsx +++ b/lib/vendors/contacts-table/contact-table.tsx @@ -16,6 +16,7 @@ import { getColumns } from "./contact-table-columns" import { getVendorContacts, } from "../service" import { VendorContact, vendors } from "@/db/schema/vendors" import { VendorsTableToolbarActions } from "./contact-table-toolbar-actions" +import { EditContactDialog } from "./edit-contact-dialog" interface VendorsTableProps { promises: Promise< @@ -33,6 +34,23 @@ export function VendorContactsTable({ promises , vendorId}: VendorsTableProps) { const [{ data, pageCount }] = React.use(promises) const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorContact> | null>(null) + const [editDialogOpen, setEditDialogOpen] = React.useState(false) + const [selectedContact, setSelectedContact] = React.useState<VendorContact | null>(null) + + // Edit 액션 처리 + React.useEffect(() => { + if (rowAction?.type === "update") { + setSelectedContact(rowAction.row.original) + setEditDialogOpen(true) + setRowAction(null) + } + }, [rowAction]) + + // 데이터 새로고침 함수 + const handleEditSuccess = React.useCallback(() => { + // 페이지를 새로고침하거나 데이터를 다시 가져오기 + window.location.reload() + }, []) // getColumns() 호출 시, router를 주입 const columns = React.useMemo( @@ -82,6 +100,13 @@ export function VendorContactsTable({ promises , vendorId}: VendorsTableProps) { <VendorsTableToolbarActions table={table} vendorId={vendorId} /> </DataTableAdvancedToolbar> </DataTable> + + <EditContactDialog + contact={selectedContact} + open={editDialogOpen} + onOpenChange={setEditDialogOpen} + onSuccess={handleEditSuccess} + /> </> ) }
\ No newline at end of file diff --git a/lib/vendors/contacts-table/edit-contact-dialog.tsx b/lib/vendors/contacts-table/edit-contact-dialog.tsx new file mode 100644 index 00000000..e123568e --- /dev/null +++ b/lib/vendors/contacts-table/edit-contact-dialog.tsx @@ -0,0 +1,231 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" + +import { + updateVendorContactSchema, + type UpdateVendorContactSchema, +} from "../validations" +import { updateVendorContact } from "../service" +import { VendorContact } from "@/db/schema/vendors" + +interface EditContactDialogProps { + contact: VendorContact | null + open: boolean + onOpenChange: (open: boolean) => void + onSuccess: () => void +} + +export function EditContactDialog({ contact, open, onOpenChange, onSuccess }: EditContactDialogProps) { + // 담당업무 옵션 + const taskOptions = [ + { value: "회사대표", label: "회사대표 President/Director" }, + { value: "영업관리", label: "영업관리 Sales Management" }, + { value: "설계/기술", label: "설계/기술 Engineering/Design" }, + { value: "구매", label: "구매 Procurement" }, + { value: "납기/출하/운송", label: "납기/출하/운송 Delivery Control" }, + { value: "PM/생산관리", label: "PM/생산관리 PM/Manufacturing" }, + { value: "품질관리", label: "품질관리 Quality Management" }, + { value: "세금계산서/납품서관리", label: "세금계산서/납품서관리 Shipping Doc. Management" }, + { value: "A/S 관리", label: "A/S 관리 A/S Management" }, + { value: "FSE", label: "FSE(야드작업자) Field Service Engineer" } + ] + + // react-hook-form 세팅 + const form = useForm<UpdateVendorContactSchema>({ + resolver: zodResolver(updateVendorContactSchema), + defaultValues: { + contactName: "", + contactPosition: "", + contactDepartment: "", + contactTask: "", + contactEmail: "", + contactPhone: "", + isPrimary: false, + }, + }) + + // contact가 변경되면 폼 초기화 + React.useEffect(() => { + if (contact) { + form.reset({ + contactName: contact.contactName || "", + contactPosition: contact.contactPosition || "", + contactDepartment: contact.contactDepartment || "", + contactTask: contact.contactTask || "", + contactEmail: contact.contactEmail || "", + contactPhone: contact.contactPhone || "", + isPrimary: contact.isPrimary || false, + }) + } + }, [contact, form]) + + async function onSubmit(data: UpdateVendorContactSchema) { + if (!contact) return + + const result = await updateVendorContact(contact.id, data) + if (result.error) { + alert(`에러: ${result.error}`) + return + } + + // 성공 시 모달 닫고 폼 리셋 + form.reset() + onOpenChange(false) + onSuccess() + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + onOpenChange(nextOpen) + } + + if (!contact) return null + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + <DialogContent> + <DialogHeader> + <DialogTitle>연락처 수정</DialogTitle> + <DialogDescription> + 연락처 정보를 수정하고 <b>Update</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + <FormField + control={form.control} + name="contactName" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자명</FormLabel> + <FormControl> + <Input placeholder="예: 홍길동" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactPosition" + render={({ field }) => ( + <FormItem> + <FormLabel>직급</FormLabel> + <FormControl> + <Input placeholder="예: 과장" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactDepartment" + render={({ field }) => ( + <FormItem> + <FormLabel>부서</FormLabel> + <FormControl> + <Input placeholder="예: 영업부" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactTask" + render={({ field }) => ( + <FormItem> + <FormLabel>담당업무</FormLabel> + <Select onValueChange={field.onChange} value={field.value || undefined}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="담당업무를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {taskOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactEmail" + render={({ field }) => ( + <FormItem> + <FormLabel>이메일</FormLabel> + <FormControl> + <Input placeholder="name@company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="contactPhone" + render={({ field }) => ( + <FormItem> + <FormLabel>전화번호</FormLabel> + <FormControl> + <Input placeholder="010-1234-5678" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button type="button" variant="outline" onClick={() => onOpenChange(false)}> + 취소 + </Button> + <Button type="submit" disabled={form.formState.isSubmitting}> + 수정 + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts index d2be43ca..5b9b1116 100644 --- a/lib/vendors/repository.ts +++ b/lib/vendors/repository.ts @@ -175,6 +175,26 @@ export const getVendorContactsById = async (id: number): Promise<VendorContact | return contact }; +export const getVendorContactById = async (id: number): Promise<VendorContact | null> => { + const contactsRes = await db.select().from(vendorContacts).where(eq(vendorContacts.id, id)).execute(); + if (contactsRes.length === 0) return null; + + const contact = contactsRes[0]; + return contact +}; + +export async function updateVendorContactById( + tx: PgTransaction<any, any, any>, + id: number, + data: Partial<VendorContact> +) { + return tx + .update(vendorContacts) + .set(data) + .where(eq(vendorContacts.id, id)) + .returning(); +} + export async function selectVendorContacts( tx: PgTransaction<any, any, any>, params: { diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index f4ba815c..e6a2a139 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -34,6 +34,8 @@ import { countVendorMaterials, selectVendorMaterials, insertVendorMaterial, + getVendorContactById, + updateVendorContactById, } from "./repository"; @@ -42,6 +44,7 @@ import type { GetVendorsSchema, GetVendorContactsSchema, CreateVendorContactSchema, + UpdateVendorContactSchema, GetVendorItemsSchema, CreateVendorItemSchema, GetRfqHistorySchema, @@ -635,6 +638,8 @@ export async function createVendorContact(input: CreateVendorContactSchema) { vendorId: input.vendorId, contactName: input.contactName, contactPosition: input.contactPosition || "", + contactDepartment: input.contactDepartment || "", + contactTask: input.contactTask || "", contactEmail: input.contactEmail, contactPhone: input.contactPhone || "", isPrimary: input.isPrimary || false, @@ -651,6 +656,35 @@ export async function createVendorContact(input: CreateVendorContactSchema) { } } +export async function updateVendorContact(id: number, input: UpdateVendorContactSchema) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + try { + const vendorContact = await getVendorContactById(id); + if (!vendorContact) { + return { data: null, error: "Contact not found" }; + } + + await db.transaction(async (tx) => { + // DB Update + await updateVendorContactById(tx, id, { + contactName: input.contactName, + contactPosition: input.contactPosition, + contactDepartment: input.contactDepartment, + contactTask: input.contactTask, + contactEmail: input.contactEmail, + contactPhone: input.contactPhone, + isPrimary: input.isPrimary, + }); + }); + + // 캐시 무효화 (협력업체 연락처 목록 등) + revalidateTag(`vendor-contacts-${vendorContact.vendorId}`); + + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} ///item diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index 44237963..88a39651 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -334,22 +334,26 @@ export const createVendorSchema = z export const createVendorContactSchema = z.object({ vendorId: z.number(), contactName: z.string() - .min(1, "Contact name is required") - .max(255, "Max length 255"), // 신규 생성 시 반드시 입력 - contactPosition: z.string().max(100, "Max length 100"), - contactEmail: z.string().email(), - contactPhone: z.string().max(50, "Max length 50").optional(), + .min(1, "담당자명은 필수 입력사항입니다.") + .max(255, "최대 255자까지 입력 가능합니다."), // 신규 생성 시 반드시 입력 + contactPosition: z.string().max(100, "최대 100자까지 입력 가능합니다."), + contactDepartment: z.string().max(100, "최대 100자까지 입력 가능합니다."), + contactTask: z.string().max(100, "최대 100자까지 입력 가능합니다."), + contactEmail: z.string().email("올바른 이메일 형식이 아닙니다."), + contactPhone: z.string().max(50, "최대 50자까지 입력 가능합니다.").optional(), isPrimary: z.boolean(), }); export const updateVendorContactSchema = z.object({ contactName: z.string() - .min(1, "Contact name is required") - .max(255, "Max length 255"), // 신규 생성 시 반드시 입력 - contactPosition: z.string().max(100, "Max length 100").optional(), - contactEmail: z.string().email().optional(), - contactPhone: z.string().max(50, "Max length 50").optional(), + .min(1, "담당자명은 필수 입력사항입니다.") + .max(255, "최대 255자까지 입력 가능합니다."), // 신규 생성 시 반드시 입력 + contactPosition: z.string().max(100, "최대 100자까지 입력 가능합니다.").optional(), + contactDepartment: z.string().max(100, "최대 100자까지 입력 가능합니다.").optional(), + contactTask: z.string().max(100, "최대 100자까지 입력 가능합니다.").optional(), + contactEmail: z.string().email("올바른 이메일 형식이 아닙니다.").optional(), + contactPhone: z.string().max(50, "최대 50자까지 입력 가능합니다.").optional(), isPrimary: z.boolean().optional(), }); |
