summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx9
-rw-r--r--lib/bidding/detail/table/bidding-detail-vendor-create-dialog.tsx2
-rw-r--r--lib/bidding/list/create-bidding-dialog.tsx43
-rw-r--r--lib/bidding/pre-quote/table/bidding-pre-quote-vendor-create-dialog.tsx2
-rw-r--r--lib/bidding/service.ts6
-rw-r--r--lib/docu-list-rule/number-type-configs/service.ts7
-rw-r--r--lib/docu-list-rule/number-type-configs/table/number-type-configs-edit-dialog.tsx277
-rw-r--r--lib/docu-list-rule/number-type-configs/table/number-type-configs-toolbar-actions.tsx251
-rw-r--r--lib/docu-list-rule/types.ts1
-rw-r--r--lib/email-template/editor/template-content-editor.tsx6
-rw-r--r--lib/email-template/table/create-template-sheet.tsx10
-rw-r--r--lib/email-template/table/update-template-sheet.tsx6
-rw-r--r--lib/permissions/permission-assignment-actions.ts83
-rw-r--r--lib/permissions/permission-group-actions.ts270
-rw-r--r--lib/permissions/permission-settings-actions.ts229
-rw-r--r--lib/permissions/service.ts434
-rw-r--r--lib/rfq-last/service.ts20
-rw-r--r--lib/rfq-last/table/create-general-rfq-dialog.tsx44
-rw-r--r--lib/rfq-last/table/rfq-table-columns.tsx18
-rw-r--r--lib/rfq-last/vendor-response/editor/quotation-items-table.tsx6
-rw-r--r--lib/rfq-last/vendor-response/rfq-attachments-dialog.tsx4
-rw-r--r--lib/rfq-last/vendor/batch-update-conditions-dialog.tsx2
-rw-r--r--lib/rfqs/service.ts2
-rw-r--r--lib/sedp/sync-form.ts40
-rw-r--r--lib/tags/service.ts348
-rw-r--r--lib/vendor-document-list/plant/document-stage-actions.ts0
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx1433
-rw-r--r--lib/vendor-document-list/plant/document-stage-toolbar.tsx2
-rw-r--r--lib/vendor-document-list/plant/document-stages-columns.tsx316
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts458
-rw-r--r--lib/vendor-document-list/plant/document-stages-table.tsx234
-rw-r--r--lib/vendor-document-list/plant/excel-import-export.ts788
-rw-r--r--lib/vendor-document-list/plant/excel-import-stage copy 2.tsx899
-rw-r--r--lib/vendor-document-list/plant/excel-import-stage copy.tsx908
-rw-r--r--lib/vendor-document-list/plant/excel-import-stage.tsx899
-rw-r--r--lib/vendor-document-list/plant/upload/columns.tsx13
-rw-r--r--lib/vendor-document-list/plant/upload/table.tsx25
-rw-r--r--lib/vendors/contacts-table/add-contact-dialog.tsx81
-rw-r--r--lib/vendors/contacts-table/contact-table.tsx25
-rw-r--r--lib/vendors/contacts-table/edit-contact-dialog.tsx231
-rw-r--r--lib/vendors/repository.ts20
-rw-r--r--lib/vendors/service.ts34
-rw-r--r--lib/vendors/validations.ts24
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(),
});