diff options
Diffstat (limited to 'lib')
32 files changed, 4899 insertions, 471 deletions
diff --git a/lib/general-contracts/detail/general-contract-detail.tsx b/lib/general-contracts/detail/general-contract-detail.tsx index 9d9f35bd..8e7a7aff 100644 --- a/lib/general-contracts/detail/general-contract-detail.tsx +++ b/lib/general-contracts/detail/general-contract-detail.tsx @@ -149,7 +149,6 @@ export default function ContractDetailPage() { items={[]} onItemsChange={() => {}} onTotalAmountChange={() => {}} - currency="USD" availableBudget={0} readOnly={contract?.contractScope === '단가' || contract?.contractScope === '물량(실적)'} /> diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx index 5176c6ce..1b9a1a06 100644 --- a/lib/general-contracts/detail/general-contract-items-table.tsx +++ b/lib/general-contracts/detail/general-contract-items-table.tsx @@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Table, TableBody, @@ -26,12 +27,13 @@ import { Save, LoaderIcon } from 'lucide-react' interface ContractItem { id?: number - project: string itemCode: string itemInfo: string specification: string quantity: number quantityUnit: string + totalWeight: number + weightUnit: string contractDeliveryDate: string contractUnitPrice: number contractAmount: number @@ -45,22 +47,27 @@ interface ContractItemsTableProps { items: ContractItem[] onItemsChange: (items: ContractItem[]) => void onTotalAmountChange: (total: number) => void - currency?: string availableBudget?: number readOnly?: boolean } +// 통화 목록 +const CURRENCIES = ["USD", "EUR", "KRW", "JPY", "CNY"]; + +// 수량 단위 목록 +const QUANTITY_UNITS = ["KG", "TON", "EA", "M", "M2", "M3", "L", "ML", "G", "SET", "PCS"]; + +// 중량 단위 목록 +const WEIGHT_UNITS = ["KG", "TON", "G", "LB", "OZ"]; + export function ContractItemsTable({ contractId, items, onItemsChange, onTotalAmountChange, - currency = 'USD', availableBudget = 0, readOnly = false }: ContractItemsTableProps) { - // 통화 코드가 null이거나 undefined일 때 기본값 설정 - const safeCurrency = currency || 'USD' const [localItems, setLocalItems] = React.useState<ContractItem[]>(items) const [isSaving, setIsSaving] = React.useState(false) const [isLoading, setIsLoading] = React.useState(false) @@ -74,16 +81,17 @@ export function ContractItemsTable({ const fetchedItems = await getContractItems(contractId) const formattedItems = fetchedItems.map(item => ({ id: item.id, - project: item.project || '', itemCode: item.itemCode || '', itemInfo: item.itemInfo || '', specification: item.specification || '', quantity: Number(item.quantity) || 0, - quantityUnit: item.quantityUnit || 'KG', + quantityUnit: item.quantityUnit || 'EA', + totalWeight: Number(item.totalWeight) || 0, + weightUnit: item.weightUnit || 'KG', contractDeliveryDate: item.contractDeliveryDate || '', contractUnitPrice: Number(item.contractUnitPrice) || 0, contractAmount: Number(item.contractAmount) || 0, - contractCurrency: item.contractCurrency || safeCurrency, + contractCurrency: item.contractCurrency || 'KRW', isSelected: false })) as ContractItem[] setLocalItems(formattedItems as ContractItem[]) @@ -99,7 +107,7 @@ export function ContractItemsTable({ } loadItems() - }, [contractId, currency, onItemsChange]) + }, [contractId, onItemsChange]) // 로컬 상태와 부모 상태 동기화 (초기 로드 후에는 부모 상태 우선) React.useEffect(() => { @@ -116,10 +124,8 @@ export function ContractItemsTable({ const errors: string[] = [] for (let index = 0; index < localItems.length; index++) { const item = localItems[index] - if (!item.project) errors.push(`${index + 1}번째 품목의 프로젝트`) if (!item.itemCode) errors.push(`${index + 1}번째 품목의 품목코드`) if (!item.itemInfo) errors.push(`${index + 1}번째 품목의 Item 정보`) - if (!item.specification) errors.push(`${index + 1}번째 품목의 사양`) if (!item.quantity || item.quantity <= 0) errors.push(`${index + 1}번째 품목의 수량`) if (!item.contractUnitPrice || item.contractUnitPrice <= 0) errors.push(`${index + 1}번째 품목의 단가`) if (!item.contractDeliveryDate) errors.push(`${index + 1}번째 품목의 납기일`) @@ -170,16 +176,17 @@ export function ContractItemsTable({ // 행 추가 const addRow = () => { const newItem: ContractItem = { - project: '', itemCode: '', itemInfo: '', specification: '', quantity: 0, - quantityUnit: 'KG', + quantityUnit: 'EA', // 기본 수량 단위 + totalWeight: 0, + weightUnit: 'KG', // 기본 중량 단위 contractDeliveryDate: '', contractUnitPrice: 0, contractAmount: 0, - contractCurrency: safeCurrency, + contractCurrency: 'KRW', // 기본 통화 isSelected: false } const updatedItems = [...localItems, newItem] @@ -213,10 +220,10 @@ export function ContractItemsTable({ // 통화 포맷팅 - const formatCurrency = (amount: number) => { + const formatCurrency = (amount: number, currency: string = 'KRW') => { return new Intl.NumberFormat('ko-KR', { style: 'currency', - currency: safeCurrency, + currency: currency, }).format(amount) } @@ -270,7 +277,7 @@ export function ContractItemsTable({ <div className="flex items-center justify-between"> <div className="flex items-center gap-2"> - <span className="text-sm text-gray-600">총 금액: {totalAmount.toLocaleString()} {currency}</span> + <span className="text-sm text-gray-600">총 금액: {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}</span> <span className="text-sm text-gray-600">총 수량: {totalQuantity.toLocaleString()}</span> </div> {!readOnly && ( @@ -316,19 +323,19 @@ export function ContractItemsTable({ <div className="space-y-1"> <Label className="text-sm font-medium">총 계약금액</Label> <div className="text-lg font-bold text-primary"> - {formatCurrency(totalAmount)} + {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')} </div> </div> <div className="space-y-1"> <Label className="text-sm font-medium">가용예산</Label> <div className="text-lg font-bold"> - {formatCurrency(availableBudget)} + {formatCurrency(availableBudget, localItems[0]?.contractCurrency || 'KRW')} </div> </div> <div className="space-y-1"> <Label className="text-sm font-medium">가용예산 比 (금액차)</Label> <div className={`text-lg font-bold ${amountDifference >= 0 ? 'text-green-600' : 'text-red-600'}`}> - {formatCurrency(amountDifference)} + {formatCurrency(amountDifference, localItems[0]?.contractCurrency || 'KRW')} </div> </div> <div className="space-y-1"> @@ -357,12 +364,13 @@ export function ContractItemsTable({ /> )} </TableHead> - <TableHead className="px-3 py-3 font-semibold">프로젝트</TableHead> <TableHead className="px-3 py-3 font-semibold">품목코드 (PKG No.)</TableHead> <TableHead className="px-3 py-3 font-semibold">Item 정보 (자재그룹 / 자재코드)</TableHead> <TableHead className="px-3 py-3 font-semibold">규격</TableHead> <TableHead className="px-3 py-3 font-semibold text-right">수량</TableHead> <TableHead className="px-3 py-3 font-semibold">수량단위</TableHead> + <TableHead className="px-3 py-3 font-semibold text-right">총 중량</TableHead> + <TableHead className="px-3 py-3 font-semibold">중량단위</TableHead> <TableHead className="px-3 py-3 font-semibold">계약납기일</TableHead> <TableHead className="px-3 py-3 font-semibold text-right">계약단가</TableHead> <TableHead className="px-3 py-3 font-semibold text-right">계약금액</TableHead> @@ -385,19 +393,6 @@ export function ContractItemsTable({ </TableCell> <TableCell className="px-3 py-3"> {readOnly ? ( - <span className="text-sm">{item.project || '-'}</span> - ) : ( - <Input - value={item.project} - onChange={(e) => updateItem(index, 'project', e.target.value)} - placeholder="프로젝트" - className="h-8 text-sm" - disabled={!isEnabled} - /> - )} - </TableCell> - <TableCell className="px-3 py-3"> - {readOnly ? ( <span className="text-sm">{item.itemCode || '-'}</span> ) : ( <Input @@ -453,17 +448,62 @@ export function ContractItemsTable({ {readOnly ? ( <span className="text-sm">{item.quantityUnit || '-'}</span> ) : ( - <Input + <Select value={item.quantityUnit} - onChange={(e) => updateItem(index, 'quantityUnit', e.target.value)} - placeholder="단위" - className="h-8 text-sm w-16" + onValueChange={(value) => updateItem(index, 'quantityUnit', value)} + disabled={!isEnabled} + > + <SelectTrigger className="h-8 text-sm w-20"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {QUANTITY_UNITS.map((unit) => ( + <SelectItem key={unit} value={unit}> + {unit} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </TableCell> + <TableCell className="px-3 py-3"> + {readOnly ? ( + <span className="text-sm text-right">{item.totalWeight.toLocaleString()}</span> + ) : ( + <Input + type="number" + value={item.totalWeight} + onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)} + className="h-8 text-sm text-right" + placeholder="0" disabled={!isEnabled} /> )} </TableCell> <TableCell className="px-3 py-3"> {readOnly ? ( + <span className="text-sm">{item.weightUnit || '-'}</span> + ) : ( + <Select + value={item.weightUnit} + onValueChange={(value) => updateItem(index, 'weightUnit', value)} + disabled={!isEnabled} + > + <SelectTrigger className="h-8 text-sm w-20"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {WEIGHT_UNITS.map((unit) => ( + <SelectItem key={unit} value={unit}> + {unit} + </SelectItem> + ))} + </SelectContent> + </Select> + )} + </TableCell> + <TableCell className="px-3 py-3"> + {readOnly ? ( <span className="text-sm">{item.contractDeliveryDate || '-'}</span> ) : ( <Input @@ -498,13 +538,22 @@ export function ContractItemsTable({ {readOnly ? ( <span className="text-sm">{item.contractCurrency || '-'}</span> ) : ( - <Input + <Select value={item.contractCurrency} - onChange={(e) => updateItem(index, 'contractCurrency', e.target.value)} - placeholder="통화" - className="h-8 text-sm w-16" + onValueChange={(value) => updateItem(index, 'contractCurrency', value)} disabled={!isEnabled} - /> + > + <SelectTrigger className="h-8 text-sm w-20"> + <SelectValue /> + </SelectTrigger> + <SelectContent> + {CURRENCIES.map((currency) => ( + <SelectItem key={currency} value={currency}> + {currency} + </SelectItem> + ))} + </SelectContent> + </Select> )} </TableCell> </TableRow> @@ -528,14 +577,14 @@ export function ContractItemsTable({ <div className="flex items-center justify-between"> <span className="text-sm font-medium text-muted-foreground">총 단가</span> <span className="text-lg font-semibold"> - {totalUnitPrice.toLocaleString()} {currency} + {formatCurrency(totalUnitPrice, localItems[0]?.contractCurrency || 'KRW')} </span> </div> <div className="border-t pt-4"> <div className="flex items-center justify-between"> <span className="text-xl font-bold text-primary">합계 금액</span> <span className="text-2xl font-bold text-primary"> - {formatCurrency(totalAmount)} + {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')} </span> </div> </div> diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts index 8c74c616..52301dae 100644 --- a/lib/general-contracts/service.ts +++ b/lib/general-contracts/service.ts @@ -372,7 +372,8 @@ export async function createContract(data: Record<string, unknown>) { try {
// 계약번호 자동 생성
// TODO: 구매 발주담당자 코드 필요 - 파라미터 추가
- const userId = data.registeredById as string
+ const rawUserId = data.registeredById
+ const userId = (rawUserId && !isNaN(Number(rawUserId))) ? String(rawUserId) : undefined
const contractNumber = await generateContractNumber(
userId,
data.type as string
@@ -676,6 +677,8 @@ export async function updateContractItems(contractId: number, items: Record<stri specification: item.specification as string,
quantity: item.quantity as number,
quantityUnit: item.quantityUnit as string,
+ totalWeight: item.totalWeight as number,
+ weightUnit: item.weightUnit as string,
contractDeliveryDate: item.contractDeliveryDate as string,
contractUnitPrice: item.contractUnitPrice as number,
contractAmount: item.contractAmount as number,
@@ -1554,8 +1557,8 @@ async function mapContractSummaryToDb(contractSummary: any) { // 계약번호 생성
const contractNumber = await generateContractNumber(
- basicInfo.contractType || basicInfo.type || 'UP',
- basicInfo.userId
+ basicInfo.userId,
+ basicInfo.contractType || basicInfo.type || 'UP'
)
return {
@@ -1584,38 +1587,38 @@ async function mapContractSummaryToDb(contractSummary: any) { currency: basicInfo.currency || basicInfo.contractCurrency || 'USD',
totalAmount: Number(basicInfo.totalAmount || basicInfo.contractAmount || 0),
- // SAP ECC 관련 필드들
- poVersion: basicInfo.revision || 1,
- purchaseDocType: basicInfo.type || 'UP',
- purchaseOrg: basicInfo.purchaseOrg || '',
- purchaseGroup: basicInfo.purchaseGroup || '',
- exchangeRate: Number(basicInfo.exchangeRate || 1),
+ // // SAP ECC 관련 필드들
+ // poVersion: basicInfo.revision || 1,
+ // purchaseDocType: basicInfo.type || 'UP',
+ // purchaseOrg: basicInfo.purchaseOrg || '',
+ // purchaseGroup: basicInfo.purchaseGroup || '',
+ // exchangeRate: Number(basicInfo.exchangeRate || 1),
- // 계약/보증 관련
- contractGuaranteeCode: basicInfo.contractGuaranteeCode || '',
- defectGuaranteeCode: basicInfo.defectGuaranteeCode || '',
- guaranteePeriodCode: basicInfo.guaranteePeriodCode || '',
- advancePaymentYn: basicInfo.advancePaymentYn || 'N',
+ // // 계약/보증 관련
+ // contractGuaranteeCode: basicInfo.contractGuaranteeCode || '',
+ // defectGuaranteeCode: basicInfo.defectGuaranteeCode || '',
+ // guaranteePeriodCode: basicInfo.guaranteePeriodCode || '',
+ // advancePaymentYn: basicInfo.advancePaymentYn || 'N',
- // 전자계약/승인 관련
- electronicContractYn: basicInfo.electronicContractYn || 'Y',
- electronicApprovalDate: basicInfo.electronicApprovalDate || null,
- electronicApprovalTime: basicInfo.electronicApprovalTime || '',
- ownerApprovalYn: basicInfo.ownerApprovalYn || 'N',
+ // // 전자계약/승인 관련
+ // electronicContractYn: basicInfo.electronicContractYn || 'Y',
+ // electronicApprovalDate: basicInfo.electronicApprovalDate || null,
+ // electronicApprovalTime: basicInfo.electronicApprovalTime || '',
+ // ownerApprovalYn: basicInfo.ownerApprovalYn || 'N',
- // 기타
- plannedInOutFlag: basicInfo.plannedInOutFlag || 'I',
- settlementStandard: basicInfo.settlementStandard || 'A',
- weightSettlementFlag: basicInfo.weightSettlementFlag || 'N',
+ // // 기타
+ // plannedInOutFlag: basicInfo.plannedInOutFlag || 'I',
+ // settlementStandard: basicInfo.settlementStandard || 'A',
+ // weightSettlementFlag: basicInfo.weightSettlementFlag || 'N',
// 연동제 관련
priceIndexYn: basicInfo.priceIndexYn || 'N',
writtenContractNo: basicInfo.contractNumber || '',
contractVersion: basicInfo.revision || 1,
- // 부분 납품/결제
- partialShippingAllowed: basicInfo.partialShippingAllowed || false,
- partialPaymentAllowed: basicInfo.partialPaymentAllowed || false,
+ // // 부분 납품/결제
+ // partialShippingAllowed: basicInfo.partialShippingAllowed || false,
+ // partialPaymentAllowed: basicInfo.partialPaymentAllowed || false,
// 메모
remarks: basicInfo.notes || basicInfo.remarks || '',
@@ -1748,7 +1751,7 @@ export async function generateContractNumber( const user = await db
.select({ userCode: users.userCode })
.from(users)
- .where(eq(users.id, userId))
+ .where(eq(users.id, parseInt(userId || '0')))
.limit(1);
if (user[0]?.userCode && user[0].userCode.length >= 3) {
purchaseManagerCode = user[0].userCode.substring(0, 3).toUpperCase();
@@ -1774,8 +1777,20 @@ export async function generateContractNumber( let sequenceNumber = 1
if (existingContracts.length > 0) {
const lastContractNumber = existingContracts[0].contractNumber
- const lastSequence = parseInt(lastContractNumber.slice(-3))
- sequenceNumber = lastSequence + 1
+ const lastSequenceStr = lastContractNumber.slice(-3)
+
+ // contractNumber에서 숫자만 추출하여 sequence 찾기
+ const numericParts = lastContractNumber.match(/\d+/g)
+ if (numericParts && numericParts.length > 0) {
+ // 마지막 숫자 부분을 시퀀스로 사용 (일반적으로 마지막 3자리)
+ const potentialSequence = numericParts[numericParts.length - 1]
+ const lastSequence = parseInt(potentialSequence)
+
+ if (!isNaN(lastSequence)) {
+ sequenceNumber = lastSequence + 1
+ }
+ }
+ // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지
}
// 일련번호를 3자리로 포맷팅
@@ -1797,8 +1812,19 @@ export async function generateContractNumber( let sequenceNumber = 1
if (existingContracts.length > 0) {
const lastContractNumber = existingContracts[0].contractNumber
- const lastSequence = parseInt(lastContractNumber.slice(-3))
- sequenceNumber = lastSequence + 1
+
+ // contractNumber에서 숫자만 추출하여 sequence 찾기
+ const numericParts = lastContractNumber.match(/\d+/g)
+ if (numericParts && numericParts.length > 0) {
+ // 마지막 숫자 부분을 시퀀스로 사용
+ const potentialSequence = numericParts[numericParts.length - 1]
+ const lastSequence = parseInt(potentialSequence)
+
+ if (!isNaN(lastSequence)) {
+ sequenceNumber = lastSequence + 1
+ }
+ }
+ // 숫자를 찾지 못했거나 파싱 실패 시 sequenceNumber = 1 유지
}
// 최종 계약번호 생성: C + 발주담당자코드(3자리) + 계약종류(2자리) + 연도(2자리) + 일련번호(3자리)
diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx index 155fd412..09c9fe35 100644 --- a/lib/rfq-last/attachment/rfq-attachments-table.tsx +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -50,6 +50,7 @@ import { AddAttachmentDialog } from "./add-attachment-dialog"; import { UpdateRevisionDialog } from "./update-revision-dialog"; import { toast } from "sonner"; import { RevisionHistoryDialog } from "./revision-historty-dialog"; +import { createFilterFn } from "@/components/client-data-table/table-filters"; // 타입 정의 interface RfqAttachment { @@ -238,6 +239,7 @@ export function RfqAttachmentsTable({ { accessorKey: "serialNo", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="일련번호" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => ( <span className="font-mono text-sm">{row.original.serialNo || "-"}</span> ), @@ -248,6 +250,7 @@ export function RfqAttachmentsTable({ { accessorKey: "originalFileName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="파일명" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => { const file = row.original; return ( @@ -266,6 +269,7 @@ export function RfqAttachmentsTable({ { accessorKey: "description", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="설명" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => ( <div className="max-w-[200px] truncate" title={row.original.description || ""}> {row.original.description || "-"} @@ -276,6 +280,7 @@ export function RfqAttachmentsTable({ { accessorKey: "currentRevision", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="리비전" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => { const revision = row.original.currentRevision; return revision ? ( @@ -291,6 +296,7 @@ export function RfqAttachmentsTable({ { accessorKey: "fileSize", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="크기" />, + filterFn: createFilterFn("number"), // number 타입으로 변경 cell: ({ row }) => ( <span className="text-sm text-muted-foreground"> {formatFileSize(row.original.fileSize)} @@ -299,14 +305,50 @@ export function RfqAttachmentsTable({ size: 80, }, { + accessorKey: "fileType", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="파일 타입" />, + filterFn: createFilterFn("select"), // 추가 + cell: ({ row }) => { + const fileType = row.original.fileType; + if (!fileType) return <span className="text-muted-foreground">-</span>; + + const type = fileType.toLowerCase(); + let displayType = "기타"; + let color = "text-gray-500"; + + if (type.includes('pdf')) { + displayType = "PDF"; + color = "text-red-500"; + } else if (type.includes('excel') || ['xls', 'xlsx'].includes(type)) { + displayType = "Excel"; + color = "text-green-500"; + } else if (type.includes('word') || ['doc', 'docx'].includes(type)) { + displayType = "Word"; + color = "text-blue-500"; + } else if (type.includes('image') || ['jpg', 'jpeg', 'png', 'gif'].includes(type)) { + displayType = "이미지"; + color = "text-purple-500"; + } + + return ( + <Badge variant="outline" className={cn("text-xs", color)}> + {displayType} + </Badge> + ); + }, + size: 100, + }, + { accessorKey: "createdByName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드자" />, + filterFn: createFilterFn("text"), // 추가 cell: ({ row }) => row.original.createdByName || "-", size: 100, }, { accessorKey: "createdAt", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업로드일" />, + filterFn: createFilterFn("date"), // date 타입으로 변경 cell: ({ row }) => { const date = row.original.createdAt; return date ? ( @@ -334,6 +376,7 @@ export function RfqAttachmentsTable({ { accessorKey: "updatedAt", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="수정일" />, + filterFn: createFilterFn("date"), // date 타입으로 변경 cell: ({ row }) => { const date = row.original.updatedAt; return date ? format(new Date(date), "MM-dd HH:mm") : "-"; @@ -341,46 +384,37 @@ export function RfqAttachmentsTable({ size: 100, }, { + accessorKey: "revisionComment", + header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="리비전 코멘트" />, + filterFn: createFilterFn("text"), // 추가 + cell: ({ row }) => { + const comment = row.original.revisionComment; + return comment ? ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <span className="text-sm truncate max-w-[150px] block cursor-help"> + {comment} + </span> + </TooltipTrigger> + <TooltipContent className="max-w-[300px]"> + <p className="text-sm">{comment}</p> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + size: 150, + }, + { id: "actions", header: "작업", cell: ({ row }) => { return ( <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button variant="ghost" className="h-8 w-8 p-0"> - <span className="sr-only">메뉴 열기</span> - <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> - </svg> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end"> - <DropdownMenuItem onClick={() => handleAction({ row, type: "download" })}> - <Download className="mr-2 h-4 w-4" /> - 다운로드 - </DropdownMenuItem> - <DropdownMenuItem onClick={() => handleAction({ row, type: "preview" })}> - <Eye className="mr-2 h-4 w-4" /> - 미리보기 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem onClick={() => handleAction({ row, type: "history" })}> - <History className="mr-2 h-4 w-4" /> - 리비전 이력 - </DropdownMenuItem> - <DropdownMenuItem onClick={() => handleAction({ row, type: "update" })}> - <Upload className="mr-2 h-4 w-4" /> - 새 버전 업로드 - </DropdownMenuItem> - <DropdownMenuSeparator /> - <DropdownMenuItem - onClick={() => handleAction({ row, type: "delete" })} - className="text-red-600" - > - <Trash2 className="mr-2 h-4 w-4" /> - 삭제 - </DropdownMenuItem> - </DropdownMenuContent> + {/* ... 기존 드롭다운 메뉴 내용 ... */} </DropdownMenu> ); }, @@ -394,18 +428,18 @@ export function RfqAttachmentsTable({ { id: "originalFileName", label: "파일명", type: "text" }, { id: "description", label: "설명", type: "text" }, { id: "currentRevision", label: "리비전", type: "text" }, - { - id: "fileType", - label: "파일 타입", - type: "select", - options: [ - { label: "PDF", value: "pdf" }, - { label: "Excel", value: "xlsx" }, - { label: "Word", value: "docx" }, - { label: "이미지", value: "image" }, - { label: "기타", value: "other" }, - ] - }, + // { + // id: "fileType", + // label: "파일 타입", + // type: "select", + // options: [ + // { label: "PDF", value: "pdf" }, + // { label: "Excel", value: "xlsx" }, + // { label: "Word", value: "docx" }, + // { label: "이미지", value: "image" }, + // { label: "기타", value: "other" }, + // ] + // }, { id: "createdByName", label: "업로드자", type: "text" }, { id: "createdAt", label: "업로드일", type: "date" }, { id: "updatedAt", label: "수정일", type: "date" }, diff --git a/lib/rfq-last/quotation-compare-view.tsx b/lib/rfq-last/quotation-compare-view.tsx index 28c8b3b1..91d46295 100644 --- a/lib/rfq-last/quotation-compare-view.tsx +++ b/lib/rfq-last/quotation-compare-view.tsx @@ -507,7 +507,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { <FileText className="h-3 w-3" /> 일반계약 </Button> - <Button + {/* <Button size="sm" variant="default" onClick={() => { @@ -518,7 +518,7 @@ export function QuotationCompareView({ data }: QuotationCompareViewProps) { > <Globe className="h-3 w-3" /> 입찰 - </Button> + </Button> */} </div> )} </div> diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts index 85db1ea7..43943c71 100644 --- a/lib/rfq-last/service.ts +++ b/lib/rfq-last/service.ts @@ -3096,7 +3096,6 @@ async function processVendors({ // PDF 저장 디렉토리 준비 const contractsDir = path.join( - process.cwd(), `${process.env.NAS_PATH}`, "contracts", "generated" diff --git a/lib/rfq-last/table/create-general-rfq-dialog.tsx b/lib/rfq-last/table/create-general-rfq-dialog.tsx index 7abf06a3..2c69f4b7 100644 --- a/lib/rfq-last/table/create-general-rfq-dialog.tsx +++ b/lib/rfq-last/table/create-general-rfq-dialog.tsx @@ -347,7 +347,7 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp {/* 스크롤 가능한 컨텐츠 영역 */} <ScrollArea className="flex-1 px-1"> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2"> + <form id="createGeneralRfqForm" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2"> {/* 기본 정보 섹션 */} <div className="space-y-4"> @@ -766,8 +766,10 @@ export function CreateGeneralRfqDialog({ onSuccess }: CreateGeneralRfqDialogProp </Button> <Button type="submit" + form="createGeneralRfqForm" onClick={form.handleSubmit(onSubmit)} disabled={isLoading} + // variant="default" > {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {isLoading ? "생성 중..." : "일반견적 생성"} diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx index d933fa95..7d48f5a4 100644 --- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx +++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx @@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button"; import { Users, RefreshCw, FileDown, Plus } from "lucide-react"; import { RfqsLastView } from "@/db/schema"; import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog"; +import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; // 추가 import { Badge } from "@/components/ui/badge"; import { Tooltip, @@ -26,6 +27,8 @@ export function RfqTableToolbarActions<TData>({ onRefresh }: RfqTableToolbarActionsProps<TData>) { const [showAssignDialog, setShowAssignDialog] = React.useState(false); + + console.log(rfqCategory) // 선택된 행 가져오기 const selectedRows = table.getFilteredSelectedRowModel().rows; @@ -52,6 +55,10 @@ export function RfqTableToolbarActions<TData>({ onRefresh?.(); }; + const handleCreateGeneralRfqSuccess = () => { + onRefresh?.(); // 테이블 데이터 새로고침 + }; + return ( <> <div className="flex items-center gap-2"> @@ -114,16 +121,8 @@ export function RfqTableToolbarActions<TData>({ </Button> {rfqCategory === "general" && ( - <Button - variant="outline" - size="sm" - className="flex items-center gap-2" - > - <Plus className="h-4 w-4" /> - 일반견적 생성 - </Button> + <CreateGeneralRfqDialog onSuccess={handleCreateGeneralRfqSuccess} /> )} - <Button variant="outline" size="sm" @@ -134,6 +133,7 @@ export function RfqTableToolbarActions<TData>({ 엑셀 다운로드 </Button> </div> + {/* 담당자 지정 다이얼로그 */} <RfqAssignPicDialog diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index 72539113..0ebcecbd 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -29,7 +29,8 @@ import { Router, Shield, CheckSquare, - GitCompare + GitCompare, + Link } from "lucide-react"; import { format } from "date-fns"; import { ko } from "date-fns/locale"; @@ -69,6 +70,7 @@ import { VendorResponseDetailDialog } from "./vendor-detail-dialog"; import { DeleteVendorDialog } from "./delete-vendor-dialog"; import { useRouter } from "next/navigation" import { EditContractDialog } from "./edit-contract-dialog"; +import { createFilterFn } from "@/components/client-data-table/table-filters"; // 타입 정의 interface RfqDetail { @@ -292,13 +294,14 @@ export function RfqVendorTable({ ); console.log(mergedData, "mergedData") + console.log(rfqId, "rfqId") // Short List 확정 핸들러 const handleShortListConfirm = React.useCallback(async () => { try { setIsUpdatingShortList(true); - + const vendorIds = selectedRows .map(vendor => vendor.vendorId) .filter(id => id != null); @@ -320,7 +323,7 @@ export function RfqVendorTable({ // 견적 비교 핸들러 const handleQuotationCompare = React.useCallback(() => { - const vendorsWithQuotation = selectedRows.filter(row => + const vendorsWithQuotation = selectedRows.filter(row => row.response?.submission?.submittedAt ); @@ -334,7 +337,7 @@ export function RfqVendorTable({ .map(v => v.vendorId) .filter(id => id != null) .join(','); - + router.push(`/evcp/rfq-last/${rfqId}/compare?vendors=${vendorIds}`); }, [selectedRows, rfqId, router]); @@ -349,8 +352,8 @@ export function RfqVendorTable({ setIsLoadingSendData(true); // 선택된 벤더 ID들 추출 - const selectedVendorIds = rfqCode?.startsWith("I")? selectedRows - .filter(v=>v.shortList) + const selectedVendorIds = rfqCode?.startsWith("I") ? selectedRows + .filter(v => v.shortList) .map(row => row.vendorId) .filter(id => id != null) : selectedRows @@ -468,7 +471,7 @@ export function RfqVendorTable({ } else { toast.success(`${data.vendors.length}개 업체에 RFQ를 발송했습니다.`); } - + // 페이지 새로고침 router.refresh(); } catch (error) { @@ -593,6 +596,8 @@ export function RfqVendorTable({ { accessorKey: "rfqCode", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="ITB/RFQ/견적 No." />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { return ( <span className="font-mono text-xs">{row.original.rfqCode || "-"}</span> @@ -603,6 +608,8 @@ export function RfqVendorTable({ { accessorKey: "vendorName", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="협력업체정보" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const vendor = row.original; return ( @@ -620,12 +627,16 @@ export function RfqVendorTable({ { accessorKey: "vendorCategory", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="업체분류" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => row.original.vendorCategory || "-", size: 100, }, { accessorKey: "vendorCountry", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="내외자 (위치)" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const country = row.original.vendorCountry; const isLocal = country === "KR" || country === "한국"; @@ -640,6 +651,8 @@ export function RfqVendorTable({ { accessorKey: "vendorGrade", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="AVL 정보 (등급)" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const grade = row.original.vendorGrade; if (!grade) return <span className="text-muted-foreground">-</span>; @@ -661,9 +674,11 @@ export function RfqVendorTable({ header: ({ column }) => ( <ClientDataTableColumnHeaderSimple column={column} title="TBE 상태" /> ), + filterFn: createFilterFn("text"), + cell: ({ row }) => { const status = row.original.tbeStatus; - + if (!status || status === "준비중") { return ( <Badge variant="outline" className="text-gray-500"> @@ -672,7 +687,7 @@ export function RfqVendorTable({ </Badge> ); } - + const statusConfig = { "진행중": { variant: "default", icon: <Clock className="h-3 w-3 mr-1" />, color: "text-blue-600" }, "검토중": { variant: "secondary", icon: <Eye className="h-3 w-3 mr-1" />, color: "text-orange-600" }, @@ -680,7 +695,7 @@ export function RfqVendorTable({ "완료": { variant: "success", icon: <CheckCircle className="h-3 w-3 mr-1" />, color: "text-green-600" }, "취소": { variant: "destructive", icon: <XCircle className="h-3 w-3 mr-1" />, color: "text-red-600" }, }[status] || { variant: "outline", icon: null, color: "text-gray-600" }; - + return ( <Badge variant={statusConfig.variant as any} className={statusConfig.color}> {statusConfig.icon} @@ -690,42 +705,44 @@ export function RfqVendorTable({ }, size: 100, }, - + { accessorKey: "tbeEvaluationResult", header: ({ column }) => ( <ClientDataTableColumnHeaderSimple column={column} title="TBE 평가" /> ), + filterFn: createFilterFn("text"), + cell: ({ row }) => { const result = row.original.tbeEvaluationResult; const status = row.original.tbeStatus; - + // TBE가 완료되지 않았으면 표시하지 않음 if (status !== "완료" || !result) { return <span className="text-xs text-muted-foreground">-</span>; } - + const resultConfig = { - "Acceptable": { - variant: "success", - icon: <CheckCircle className="h-3 w-3" />, + "Acceptable": { + variant: "success", + icon: <CheckCircle className="h-3 w-3" />, text: "적합", color: "bg-green-50 text-green-700 border-green-200" }, - "Acceptable with Comment": { - variant: "warning", - icon: <AlertCircle className="h-3 w-3" />, + "Acceptable with Comment": { + variant: "warning", + icon: <AlertCircle className="h-3 w-3" />, text: "조건부 적합", color: "bg-yellow-50 text-yellow-700 border-yellow-200" }, - "Not Acceptable": { - variant: "destructive", - icon: <XCircle className="h-3 w-3" />, + "Not Acceptable": { + variant: "destructive", + icon: <XCircle className="h-3 w-3" />, text: "부적합", color: "bg-red-50 text-red-700 border-red-200" }, }[result]; - + return ( <TooltipProvider> <Tooltip> @@ -755,6 +772,8 @@ export function RfqVendorTable({ { accessorKey: "contractRequirements", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="기본계약 요청" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const vendor = row.original; const isKorean = vendor.vendorCountry === "KR" || vendor.vendorCountry === "한국"; @@ -833,6 +852,8 @@ export function RfqVendorTable({ { accessorKey: "sendVersion", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="발송 회차" />, + filterFn: createFilterFn("number"), + cell: ({ row }) => { const version = row.original.sendVersion; @@ -844,6 +865,8 @@ export function RfqVendorTable({ { accessorKey: "emailStatus", header: "이메일 상태", + filterFn: createFilterFn("text"), + cell: ({ row }) => { const response = row.original; const emailSentAt = response?.emailSentAt; @@ -936,6 +959,8 @@ export function RfqVendorTable({ { accessorKey: "currency", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="요청 통화" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const currency = row.original.currency; return currency ? ( @@ -949,6 +974,8 @@ export function RfqVendorTable({ { accessorKey: "paymentTermsCode", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="지급조건" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const code = row.original.paymentTermsCode; const desc = row.original.paymentTermsDescription; @@ -972,12 +999,16 @@ export function RfqVendorTable({ { accessorKey: "taxCode", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Tax" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => row.original.taxCode || "-", size: 60, }, { accessorKey: "deliveryDate", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="계약납기일/기간" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const deliveryDate = row.original.deliveryDate; const contractDuration = row.original.contractDuration; @@ -1003,6 +1034,8 @@ export function RfqVendorTable({ { accessorKey: "incotermsCode", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Incoterms" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const code = row.original.incotermsCode; const detail = row.original.incotermsDetail; @@ -1030,6 +1063,8 @@ export function RfqVendorTable({ { accessorKey: "placeOfShipping", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="선적지" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const place = row.original.placeOfShipping; return place ? ( @@ -1046,6 +1081,7 @@ export function RfqVendorTable({ { accessorKey: "placeOfDestination", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="도착지" />, + filterFn: createFilterFn("text"), cell: ({ row }) => { const place = row.original.placeOfDestination; return place ? ( @@ -1062,6 +1098,8 @@ export function RfqVendorTable({ { id: "additionalConditions", header: "추가조건", + filterFn: createFilterFn("text"), + cell: ({ row }) => { const conditions = formatAdditionalConditions(row.original); if (conditions === "-") { @@ -1084,6 +1122,8 @@ export function RfqVendorTable({ { accessorKey: "response.submission.submittedAt", header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="참여여부 (회신일)" />, + filterFn: createFilterFn("text"), + cell: ({ row }) => { const participationRepliedAt = row.original.response?.attend?.participationRepliedAt; @@ -1131,6 +1171,7 @@ export function RfqVendorTable({ }, ...(rfqCode?.startsWith("I") ? [{ accessorKey: "shortList", + filterFn: createFilterFn("boolean"), // boolean으로 변경 header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="Short List" />, cell: ({ row }) => ( row.original.shortList ? ( @@ -1143,6 +1184,7 @@ export function RfqVendorTable({ }] : []), { accessorKey: "updatedByUserName", + filterFn: createFilterFn("text"), // 추가 header: ({ column }) => <ClientDataTableColumnHeaderSimple column={column} title="최신수정자" />, cell: ({ row }) => { const name = row.original.updatedByUserName; @@ -1238,24 +1280,160 @@ export function RfqVendorTable({ } ], [handleAction, rfqCode, isLoadingSendData]); + // advancedFilterFields 정의 - columns와 매칭되도록 정리 const advancedFilterFields: DataTableAdvancedFilterField<any>[] = [ - { id: "vendorName", label: "벤더명", type: "text" }, - { id: "vendorCode", label: "벤더코드", type: "text" }, - { id: "vendorCountry", label: "국가", type: "text" }, { - id: "response.status", - label: "응답 상태", + id: "rfqCode", + label: "ITB/RFQ/견적 No.", + type: "text" + }, + { + id: "vendorName", + label: "협력업체명", + type: "text" + }, + { + id: "vendorCode", + label: "협력업체코드", + type: "text" + }, + { + id: "vendorCategory", + label: "업체분류", + type: "select", + options: [ + { label: "제조업체", value: "제조업체" }, + { label: "무역업체", value: "무역업체" }, + { label: "대리점", value: "대리점" }, + // 실제 카테고리에 맞게 추가 + ] + }, + { + id: "vendorCountry", + label: "내외자(위치)", + type: "select", + options: [ + { label: "한국(KR)", value: "KR" }, + { label: "한국", value: "한국" }, + { label: "중국(CN)", value: "CN" }, + { label: "일본(JP)", value: "JP" }, + { label: "미국(US)", value: "US" }, + { label: "독일(DE)", value: "DE" }, + // 필요한 국가 추가 + ] + }, + { + id: "vendorGrade", + label: "AVL 등급", type: "select", options: [ - { label: "초대됨", value: "초대됨" }, - { label: "작성중", value: "작성중" }, - { label: "제출완료", value: "제출완료" }, - { label: "수정요청", value: "수정요청" }, - { label: "최종확정", value: "최종확정" }, + { label: "A", value: "A" }, + { label: "B", value: "B" }, + { label: "C", value: "C" }, + { label: "D", value: "D" }, + ] + }, + { + id: "tbeStatus", + label: "TBE 상태", + type: "select", + options: [ + { label: "대기", value: "준비중" }, + { label: "진행중", value: "진행중" }, + { label: "검토중", value: "검토중" }, + { label: "보류", value: "보류" }, + { label: "완료", value: "완료" }, { label: "취소", value: "취소" }, ] }, { + id: "tbeEvaluationResult", + label: "TBE 평가결과", + type: "select", + options: [ + { label: "적합", value: "Acceptable" }, + { label: "조건부 적합", value: "Acceptable with Comment" }, + { label: "부적합", value: "Not Acceptable" }, + ] + }, + { + id: "sendVersion", + label: "발송 회차", + type: "number" + }, + { + id: "emailStatus", + label: "이메일 상태", + type: "select", + options: [ + { label: "미발송", value: "미발송" }, + { label: "발송됨", value: "sent" }, + { label: "발송 실패", value: "failed" }, + ] + }, + { + id: "currency", + label: "요청 통화", + type: "select", + options: [ + { label: "KRW", value: "KRW" }, + { label: "USD", value: "USD" }, + { label: "EUR", value: "EUR" }, + { label: "JPY", value: "JPY" }, + { label: "CNY", value: "CNY" }, + ] + }, + { + id: "paymentTermsCode", + label: "지급조건", + type: "text" + }, + { + id: "taxCode", + label: "Tax", + type: "text", + }, + { + id: "deliveryDate", + label: "계약납기일", + type: "date" + }, + { + id: "contractDuration", + label: "계약기간", + type: "text" + }, + { + id: "incotermsCode", + label: "Incoterms", + type: "text", + }, + { + id: "placeOfShipping", + label: "선적지", + type: "text" + }, + { + id: "placeOfDestination", + label: "도착지", + type: "text" + }, + { + id: "firstYn", + label: "초도품", + type: "boolean" + }, + { + id: "materialPriceRelatedYn", + label: "연동제", + type: "boolean" + }, + { + id: "sparepartYn", + label: "스페어파트", + type: "boolean" + }, + ...(rfqCode?.startsWith("I") ? [{ id: "shortList", label: "Short List", type: "select", @@ -1263,7 +1441,12 @@ export function RfqVendorTable({ { label: "선정", value: "true" }, { label: "대기", value: "false" }, ] - }, + }] : []), + { + id: "updatedByUserName", + label: "최신수정자", + type: "text" + } ]; // 선택된 벤더 정보 (BatchUpdate용) @@ -1280,15 +1463,27 @@ export function RfqVendorTable({ // 참여 의사가 있는 선택된 벤더 수 계산 const participatingCount = selectedRows.length; - const shortListCount = selectedRows.filter(v=>v.shortList).length; + const shortListCount = selectedRows.filter(v => v.shortList).length; // 견적서가 있는 선택된 벤더 수 계산 - const quotationCount = selectedRows.filter(row => + const quotationCount = selectedRows.filter(row => row.response?.submission?.submittedAt ).length; return ( <div className="flex items-center gap-2"> + {(rfqCode?.startsWith("I") || rfqCode?.startsWith("R")) && + + <Button + variant="outline" + size="sm" + > + <Link className="h-4 w-4 mr-2" /> + AVL 연동 + </Button> + } + + <Button variant="outline" size="sm" @@ -1298,32 +1493,32 @@ export function RfqVendorTable({ <Plus className="h-4 w-4 mr-2" /> 벤더 추가 </Button> - + {selectedRows.length > 0 && ( <> {/* Short List 확정 버튼 */} - {rfqCode?.startsWith("I")&& - <Button - variant="outline" - size="sm" - onClick={handleShortListConfirm} - disabled={isUpdatingShortList } + {rfqCode?.startsWith("I") && + <Button + variant="outline" + size="sm" + onClick={handleShortListConfirm} + disabled={isUpdatingShortList} // className={ "border-green-500 text-green-600 hover:bg-green-50" } - > - {isUpdatingShortList ? ( - <> - <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - 처리중... - </> - ) : ( - <> - <CheckSquare className="h-4 w-4 mr-2" /> - Short List 확정 - {participatingCount > 0 && ` (${participatingCount})`} - </> - )} - </Button> - } + > + {isUpdatingShortList ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 처리중... + </> + ) : ( + <> + <CheckSquare className="h-4 w-4 mr-2" /> + Short List 확정 + {participatingCount > 0 && ` (${participatingCount})`} + </> + )} + </Button> + } {/* 견적 비교 버튼 */} <Button @@ -1334,7 +1529,7 @@ export function RfqVendorTable({ className={quotationCount >= 2 ? "border-blue-500 text-blue-600 hover:bg-blue-50" : ""} > <GitCompare className="h-4 w-4 mr-2" /> - 견적 비교 + 견적 비교 {quotationCount > 0 && ` (${quotationCount})`} </Button> @@ -1370,7 +1565,7 @@ export function RfqVendorTable({ </Button> </> )} - + <Button variant="outline" size="sm" @@ -1464,19 +1659,19 @@ export function RfqVendorTable({ /> )} - {/* 기본계약 수정 다이얼로그 - 새로 추가 */} - {editContractVendor && ( - <EditContractDialog - open={!!editContractVendor} - onOpenChange={(open) => !open && setEditContractVendor(null)} - rfqId={rfqId} - vendor={editContractVendor} - onSuccess={() => { - setEditContractVendor(null); - router.refresh(); - }} - /> - )} + {/* 기본계약 수정 다이얼로그 - 새로 추가 */} + {editContractVendor && ( + <EditContractDialog + open={!!editContractVendor} + onOpenChange={(open) => !open && setEditContractVendor(null)} + rfqId={rfqId} + vendor={editContractVendor} + onSuccess={() => { + setEditContractVendor(null); + router.refresh(); + }} + /> + )} </> ); }
\ No newline at end of file diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts index ede2963f..13c51824 100644 --- a/lib/vendor-document-list/import-service.ts +++ b/lib/vendor-document-list/import-service.ts @@ -506,7 +506,7 @@ class ImportService { // DOLCE FileInfo API 응답 구조에 맞게 처리 if (data.FileInfoListResult) { const files = data.FileInfoListResult as DOLCEFileInfo[] - const activeFiles = files.filter(f => f.UseYn === 'Y') + const activeFiles = files.filter(f => f.UseYn === 'True') debugSuccess(`DOLCE 파일 정보 조회 완료`, { uploadId, totalFiles: files.length, @@ -885,7 +885,7 @@ class ImportService { const fileInfos = await this.fetchFileInfoFromDOLCE(detailDoc.UploadId) for (const fileInfo of fileInfos) { - if (fileInfo.UseYn !== 'Y') { + if (fileInfo.UseYn !== 'True') { debugProcess(`비활성 파일 스킵`, { fileName: fileInfo.FileName }) continue } @@ -1578,10 +1578,10 @@ async getImportStatus( if (detailDoc.Category === 'FS' && detailDoc.UploadId) { try { const fileInfos = await this.fetchFileInfoFromDOLCE(detailDoc.UploadId) - availableAttachments += fileInfos.filter(f => f.UseYn === 'Y').length + availableAttachments += fileInfos.filter(f => f.UseYn === 'True').length for (const fileInfo of fileInfos) { - if (fileInfo.UseYn !== 'Y') continue + if (fileInfo.UseYn !== 'True') continue const existingAttachment = await db .select({ id: documentAttachments.id }) diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 26f6b638..f49d7d47 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -31,7 +31,7 @@ import { SelectValue, } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" -import { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle} from "lucide-react" import { toast } from "sonner" import { @@ -109,11 +109,10 @@ export function AddDocumentDialog({ const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([]) const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([]) + const [isLoadingInitialData, setIsLoadingInitialData] = React.useState(false) + const [isSubmitting, setIsSubmitting] = React.useState(false) - console.log(documentNumberTypes, "documentNumberTypes") - console.log(documentClassOptions, "documentClassOptions") - const [formData, setFormData] = React.useState({ documentNumberTypeId: "", documentClassId: "", @@ -126,12 +125,13 @@ export function AddDocumentDialog({ // Load initial data React.useEffect(() => { if (open) { + resetForm() // 폼 리셋 추가 loadInitialData() } }, [open]) const loadInitialData = async () => { - setIsLoading(true) + setIsLoadingInitialData(true) // isLoading 대신 try { const [typesResult, classesResult] = await Promise.all([ getDocumentNumberTypes(contractId), @@ -147,7 +147,7 @@ export function AddDocumentDialog({ } catch (error) { toast.error("Error loading data.") } finally { - setIsLoading(false) + setIsLoadingInitialData(false) } } @@ -284,7 +284,7 @@ export function AddDocumentDialog({ return } - setIsLoading(true) + setIsSubmitting(true) // isLoading 대신 try { const result = await createDocument({ contractId, @@ -307,7 +307,7 @@ export function AddDocumentDialog({ } catch (error) { toast.error("Error adding document.") } finally { - setIsLoading(false) + setIsSubmitting(false) // isLoading 대신 } } @@ -513,11 +513,11 @@ export function AddDocumentDialog({ )} <DialogFooter className="flex-shrink-0"> - <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}> Cancel </Button> - <Button onClick={handleSubmit} disabled={isLoading || !isFormValid()}> - {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} + <Button onClick={handleSubmit} disabled={isSubmitting || !isFormValid()}> + {isSubmitting ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} Add Document </Button> </DialogFooter> @@ -532,7 +532,7 @@ export function AddDocumentDialog({ interface EditDocumentDialogProps { open: boolean onOpenChange: (open: boolean) => void - document: DocumentStagesOnlyView | null + document: StageDocumentsView | null contractId: number projectType: "ship" | "plant" } @@ -753,7 +753,7 @@ export function EditDocumentDialog({ interface EditStageDialogProps { open: boolean onOpenChange: (open: boolean) => void - document: DocumentStagesOnlyView | null + document: StageDocumentsView | null stageId: number | null } @@ -1290,7 +1290,7 @@ export function ExcelImportDialog({ interface DeleteDocumentsDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { - documents: Row<DocumentStagesOnlyView>["original"][] + documents: Row<StageDocumentsView>["original"][] showTrigger?: boolean onSuccess?: () => void } diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx index 87b221b7..601a9152 100644 --- a/lib/vendor-document-list/plant/document-stage-toolbar.tsx +++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx @@ -1,11 +1,10 @@ "use client" import * as React from "react" -import { type DocumentStagesOnlyView } from "@/db/schema" +import { type StageDocumentsView } from "@/db/schema" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Plus, FileSpreadsheet } from "lucide-react" +import { Download, RefreshCw, Send, CheckCircle, AlertCircle, Plus, FileSpreadsheet, Loader2 } from "lucide-react" import { toast } from "sonner" - import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" @@ -15,12 +14,17 @@ import { 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 (필요한 경우) // import { importDocumentsExcel } from "./document-stages-service" interface DocumentsTableToolbarActionsProps { - table: Table<DocumentStagesOnlyView> + table: Table<StageDocumentsView> contractId: number projectType: "ship" | "plant" } @@ -33,6 +37,43 @@ export function DocumentsTableToolbarActions({ // 다이얼로그 상태 관리 const [showAddDialog, setShowAddDialog] = React.useState(false) const [showExcelImportDialog, setShowExcelImportDialog] = React.useState(false) + const [isSending, setIsSending] = React.useState(false) + const router = useRouter() + + // 자동 폴링 훅 사용 + const { + isPolling, + lastPolledAt, + pollingStatus, + pollDocuments + } = useDocumentPolling({ + contractId, + autoStart: true, + onUpdate: () => { + // 테이블 새로고침 + router.refresh() + } + }) + + async function handleSendToSHI() { + setIsSending(true) + try { + const result = await sendDocumentsToSHI(contractId) + + if (result.success) { + toast.success(result.message) + router.refresh() + // 테이블 새로고침 + } else { + toast.error(result.message) + } + } catch (error) { + toast.error("전송 중 오류가 발생했습니다.") + } finally { + setIsSending(false) + } + } + const handleExcelImport = () => { setShowExcelImportDialog(true) @@ -50,17 +91,28 @@ export function DocumentsTableToolbarActions({ }) } + + + return ( <div className="flex items-center gap-2"> + + + {/* 1) 선택된 문서가 있으면 삭제 다이얼로그 */} - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - <DeleteDocumentsDialog - documents={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} + {(() => { + const selectedRows = table.getFilteredSelectedRowModel().rows; + const deletableDocuments = selectedRows + .map((row) => row.original)s + .filter((doc) => !doc.buyerSystemStatus); // buyerSystemStatus가 null인 것만 필터링 + + return deletableDocuments.length > 0 ? ( + <DeleteDocumentsDialog + documents={deletableDocuments} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null; + })()} {/* 2) 새 문서 추가 다이얼로그 */} @@ -76,9 +128,45 @@ export function DocumentsTableToolbarActions({ projectType={projectType} /> + {/* SHI 전송 버튼 */} + <Button + variant="samsung" + size="sm" + onClick={handleSendToSHI} + disabled={isSending} + className="gap-2" + > + {isSending ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Sending.. + </> + ) : ( + <> + <Send className="h-4 w-4" /> + Send to SHI + </> + )} + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => pollDocuments(true)} + disabled={isPolling} + className="gap-2" + > + <RefreshCw className={cn( + "h-4 w-4", + isPolling && "animate-spin" + )} /> + Sync from SHI + </Button> + + <Button onClick={handleExcelImport} variant="outline" size="sm"> <FileSpreadsheet className="mr-2 h-4 w-4" /> - Excel Import + Excel Import </Button> <ExcelImportDialog diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx index aee47029..2f8fd482 100644 --- a/lib/vendor-document-list/plant/document-stages-columns.tsx +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -6,7 +6,7 @@ import { formatDate, formatDateTime } from "@/lib/utils" import { Checkbox } from "@/components/ui/checkbox" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { DataTableRowAction } from "@/types/table" -import { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { DropdownMenu, DropdownMenuContent, @@ -28,12 +28,17 @@ import { Eye, Edit, Plus, - Trash2 + Trash2,MessageSquare } from "lucide-react" import { cn } from "@/lib/utils" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesOnlyView> | null>> + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageDocumentsView> | null>> projectType: string domain?: "evcp" | "partners" // 선택적 파라미터로 유지 } @@ -139,11 +144,11 @@ export function getDocumentStagesColumns({ setRowAction, projectType, domain = "partners", // 기본값 설정 -}: GetColumnsProps): ColumnDef<DocumentStagesOnlyView>[] { +}: GetColumnsProps): ColumnDef<StageDocumentsView>[] { const isPlantProject = projectType === "plant" const isEvcpDomain = domain === "evcp" - const columns: ColumnDef<DocumentStagesOnlyView>[] = [ + const columns: ColumnDef<StageDocumentsView>[] = [ // 체크박스 선택 { id: "select", @@ -315,6 +320,75 @@ export function getDocumentStagesColumns({ // 나머지 공통 컬럼들 columns.push( // 현재 스테이지 (상태, 담당자 한 줄) + + { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Status" /> + ), + cell: ({ row }) => { + const doc = row.original + + return ( + <div className="flex items-center gap-2"> + <Badge + variant={getStatusColor(doc.status || false)} + className="text-xs px-1.5 py-0" + > + {getStatusText(doc.status || '')} + </Badge> + </div> + ) + }, + size: 180, + enableResizing: true, + meta: { + excelHeader: "Document Status" + }, + }, + + { + 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> + } + } + + 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 }) => ( @@ -486,7 +560,7 @@ export function getDocumentStagesColumns({ label: "Delete Document", icon: Trash2, action: () => setRowAction({ row, type: "delete" }), - show: true, + show: !doc.buyerSystemStatus, // null일 때만 true className: "text-red-600 dark:text-red-400", shortcut: "⌘⌫" } diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx index ca5e9c5b..72a804a8 100644 --- a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx +++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx @@ -2,7 +2,7 @@ "use client" import React from "react" -import { DocumentStagesOnlyView } from "@/db/schema" +import { StageDocumentsView } from "@/db/schema" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -40,7 +40,7 @@ import { toast } from "sonner" import { updateStage } from "./document-stages-service" interface DocumentStagesExpandedContentProps { - document: DocumentStagesOnlyView + document: StageDocumentsView onEditStage: (stageId: number) => void projectType: "ship" | "plant" } diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 57f17bae..30a235c3 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 { 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, issueStages, stageDocuments, stageDocumentsView, stageIssueStages } from "@/db/schema" import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm" import { createDocumentSchema, @@ -32,6 +32,7 @@ import { GetEnhancedDocumentsSchema, GetDocumentsSchema } from "../enhanced-docu 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" interface UpdateDocumentData { documentId: number @@ -810,7 +811,7 @@ export async function getDocumentClassOptions(documentClassId: number) { eq(documentClassOptions.isActive, true) ) ) - // .orderBy(asc(documentClassOptions.sortOrder)) + .orderBy(asc(documentClassOptions.sdq)) return { success: true, data: options } } catch (error) { @@ -920,6 +921,8 @@ export async function createDocument(data: CreateDocumentData) { }, }) + console.log(contract,"contract") + if (!contract) { return { success: false, error: "유효하지 않은 계약(ID)입니다." } } @@ -1053,7 +1056,7 @@ export async function getDocumentStagesOnly( finalWhere = and( advancedWhere, globalWhere, - eq(documentStagesOnlyView.contractId, contractId) + eq(stageDocumentsView.contractId, contractId) ) } @@ -1066,7 +1069,7 @@ export async function getDocumentStagesOnly( ? desc(stageDocumentsView[item.id]) : asc(stageDocumentsView[item.id]) ) - : [desc(documentStagesOnlyView.createdAt)] + : [desc(stageDocumentsView.createdAt)] // 트랜잭션 실행 @@ -1183,3 +1186,115 @@ export async function getDocumentsByStageStats(contractId: number) { return [] } } + + +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) + return { + success: false, + message: error instanceof Error ? error.message : "전송 중 오류가 발생했습니다." + } + } +} + +export async function pullDocumentStatusFromSHI( + contractId: number, +) { + try { + const api = new ShiBuyerSystemAPI() + const result = await api.pullDocumentStatus(contractId) + + // 캐시 무효화 + revalidatePath(`/partners/document-list-only/${contractId}`) + + return result + } catch (error) { + console.error("문서 상태 풀링 실패:", error) + return { + success: false, + message: error instanceof Error ? error.message : "상태 가져오기 중 오류가 발생했습니다." + } + } +} + + +interface FileValidation { + projectId: number + docNumber: string + stageName: string + revision: string +} + +interface ValidationResult { + projectId: number + docNumber: string + stageName: string + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: number + } +} + +export async function validateFiles(files: FileValidation[]): Promise<ValidationResult[]> { + const session = await getServerSession(authOptions) + 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 + .select({ + documentId: stageSubmissionView.documentId, + stageId: stageSubmissionView.stageId, + documentTitle: stageSubmissionView.documentTitle, + latestRevisionNumber: stageSubmissionView.latestRevisionNumber, + }) + .from(stageSubmissionView) + .where( + and( + eq(stageSubmissionView.vendorId, vendorId), + eq(stageSubmissionView.projectId, file.projectId), + eq(stageSubmissionView.docNumber, file.docNumber), + eq(stageSubmissionView.stageName, file.stageName) + ) + ) + .limit(1) + + if (match.length > 0) { + results.push({ + projectId: file.projectId, + docNumber: file.docNumber, + stageName: file.stageName, + matched: { + documentId: match[0].documentId, + stageId: match[0].stageId!, + documentTitle: match[0].documentTitle, + currentRevision: match[0].latestRevisionNumber || 0, + } + }) + } else { + results.push({ + projectId: file.projectId, + docNumber: file.docNumber, + stageName: file.stageName, + }) + } + } + + return results +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 3d2ddafd..50d54a92 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -9,7 +9,7 @@ import type { import { useDataTable } from "@/hooks/use-data-table" import { getDocumentStagesOnly } from "./document-stages-service" -import type { DocumentStagesOnlyView } from "@/db/schema" +import type { StageDocumentsView } from "@/db/schema" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" @@ -51,17 +51,17 @@ export function DocumentStagesTable({ const { data: session } = useSession() - + // URL에서 언어 파라미터 가져오기 const params = useParams() const lng = (params?.lng as string) || 'ko' const { t } = useTranslation(lng, 'document') - // 세션에서 도메인을 가져오기 - const currentDomain = session?.user?.domain as "evcp" | "partners" + // 세션에서 도메인을 가져오기 + const currentDomain = session?.user?.domain as "evcp" | "partners" // 상태 관리 - const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesOnlyView> | null>(null) + 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') @@ -72,7 +72,7 @@ export function DocumentStagesTable({ const [excelImportOpen, setExcelImportOpen] = React.useState(false) // 선택된 항목들 - const [selectedDocument, setSelectedDocument] = React.useState<DocumentStagesOnlyView | null>(null) + const [selectedDocument, setSelectedDocument] = React.useState<StageDocumentsView | null>(null) const [selectedStageId, setSelectedStageId] = React.useState<number | null>(null) // 컬럼 정의 @@ -116,7 +116,7 @@ export function DocumentStagesTable({ 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 => @@ -138,7 +138,7 @@ export function DocumentStagesTable({ highPriority, avgProgress } - + console.log('DocumentStagesTable - stats:', result) return result }, [data]) @@ -201,10 +201,10 @@ export function DocumentStagesTable({ } // 필터 필드 정의 - const filterFields: DataTableFilterField<DocumentStagesOnlyView>[] = [ + const filterFields: DataTableFilterField<StageDocumentsView>[] = [ ] - const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesOnlyView>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<StageDocumentsView>[] = [ { id: "docNumber", label: "문서번호", diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts index 3ddb7195..c1409205 100644 --- a/lib/vendor-document-list/plant/excel-import-export.ts +++ b/lib/vendor-document-list/plant/excel-import-export.ts @@ -10,7 +10,7 @@ import { type ExcelImportResult, type CreateDocumentInput } from './document-stage-validations' -import { DocumentStagesOnlyView } from '@/db/schema' +import { StageDocumentsView } from '@/db/schema' // ============================================================================= // 1. 엑셀 템플릿 생성 및 다운로드 @@ -510,7 +510,7 @@ function formatExcelDate(value: any): string | undefined { // 문서 데이터를 엑셀로 익스포트 export function exportDocumentsToExcel( - documents: DocumentStagesOnlyView[], + documents: StageDocumentsView[], projectType: "ship" | "plant" ) { const headers = [ @@ -609,7 +609,7 @@ export function exportDocumentsToExcel( } // 스테이지 상세 데이터를 엑셀로 익스포트 -export function exportStageDetailsToExcel(documents: DocumentStagesOnlyView[]) { +export function exportStageDetailsToExcel(documents: StageDocumentsView[]) { const headers = [ "문서번호", "문서명", diff --git a/lib/vendor-document-list/plant/shi-buyer-system-api.ts b/lib/vendor-document-list/plant/shi-buyer-system-api.ts new file mode 100644 index 00000000..1f15efa6 --- /dev/null +++ b/lib/vendor-document-list/plant/shi-buyer-system-api.ts @@ -0,0 +1,874 @@ +// app/lib/shi-buyer-system-api.ts +import db from "@/db/db" +import { stageDocuments, stageIssueStages, contracts, vendors, projects, stageSubmissions, stageSubmissionAttachments } from "@/db/schema" +import { eq, and, sql, ne } from "drizzle-orm" +import fs from 'fs/promises' +import path from 'path' + +interface ShiDocumentInfo { + PROJ_NO: string + SHI_DOC_NO: string + CATEGORY: string + RESPONSIBLE_CD: string + RESPONSIBLE: string + VNDR_CD: string + VNDR_NM: string + DSN_SKL: string + MIFP_CD: string + MIFP_NM: string + CG_EMPNO1: string + CG_EMPNM1: string + OWN_DOC_NO: string + DSC: string + DOC_CLASS: string + COMMENT: string + STATUS: string + CRTER: string + CRTE_DTM: string + CHGR: string + CHG_DTM: string +} + +interface ShiScheduleInfo { + PROJ_NO: string + SHI_DOC_NO: string + DDPKIND: string + SCHEDULE_TYPE: string + BASELINE1: string | null + REVISED1: string | null + FORECAST1: string | null + ACTUAL1: string | null + BASELINE2: string | null + REVISED2: string | null + FORECAST2: string | null + ACTUAL2: string | null + CRTER: string + CRTE_DTM: string + CHGR: string + CHG_DTM: string +} + +// SHI API 응답 타입 +interface ShiDocumentResponse { + PROJ_NO: string + SHI_DOC_NO: string + STATUS: string + COMMENT: string | null + CATEGORY?: string + RESPONSIBLE_CD?: string + RESPONSIBLE?: string + VNDR_CD?: string + VNDR_NM?: string + DSN_SKL?: string + MIFP_CD?: string + MIFP_NM?: string + CG_EMPNO1?: string + CG_EMPNM1?: string + OWN_DOC_NO?: string + DSC?: string + DOC_CLASS?: string + CRTER?: string + CRTE_DTM?: string + CHGR?: string + CHG_DTM?: string +} + +interface ShiApiResponse { + GetDwgInfoResult: ShiDocumentResponse[] +} + +// InBox 파일 정보 인터페이스 추가 +interface InBoxFileInfo { + PROJ_NO: string + SHI_DOC_NO: string + STAGE_NAME: string + REVISION_NO: string + VNDR_CD: string + VNDR_NM: string + FILE_NAME: string + FILE_SIZE: number + CONTENT_TYPE: string + UPLOAD_DATE: string + UPLOADED_BY: string + STATUS: string + COMMENT: string +} + +// SaveInBoxList API 응답 인터페이스 +interface SaveInBoxListResponse { + SaveInBoxListResult: { + success: boolean + message: string + processedCount?: number + files?: Array<{ + fileName: string + networkPath: string + status: string + }> + } +} + +export class ShiBuyerSystemAPI { + private baseUrl = process.env.SWP_BASE_URL || 'http://60.100.99.217/DDP/Services/VNDRService.svc' + private ddcUrl = process.env.DDC_BASE_URL || 'http://60.100.99.217/DDC/Services/WebService.svc' + private localStoragePath = process.env.NAS_PATH || './uploads' + + async sendToSHI(contractId: number) { + try { + // 1. 전송할 문서 조회 + const documents = await this.getDocumentsToSend(contractId) + + if (documents.length === 0) { + return { success: false, message: "전송할 문서가 없습니다." } + } + + // 2. 도서 정보 전송 + await this.sendDocumentInfo(documents) + + // 3. 스케줄 정보 전송 + await this.sendScheduleInfo(documents) + + // 4. 동기화 상태 업데이트 + await this.updateSyncStatus(documents.map(d => d.documentId)) + + return { + success: true, + message: `${documents.length}개 문서가 성공적으로 전송되었습니다.`, + count: documents.length + } + } catch (error) { + console.error("SHI 전송 오류:", error) + + // 에러 시 동기화 상태 업데이트 + await this.updateSyncError( + contractId, + error instanceof Error ? error.message : "알 수 없는 오류" + ) + + throw error + } + } + + private async getDocumentsToSend(contractId: number) { + const result = await db + .select({ + documentId: stageDocuments.id, + docNumber: stageDocuments.docNumber, + vendorDocNumber: stageDocuments.vendorDocNumber, + title: stageDocuments.title, + status: stageDocuments.status, + projectCode: sql<string>`(SELECT code FROM projects WHERE id = ${stageDocuments.projectId})`, + vendorCode: sql<string>`(SELECT vendor_code FROM vendors WHERE id = ${stageDocuments.vendorId})`, + vendorName: sql<string>`(SELECT vendor_name FROM vendors WHERE id = ${stageDocuments.vendorId})`, + stages: sql<any[]>` + COALESCE( + (SELECT json_agg(row_to_json(s.*)) + FROM stage_issue_stages s + WHERE s.document_id = ${stageDocuments.id} + ORDER BY s.stage_order), + '[]'::json + ) + ` + }) + .from(stageDocuments) + .where( + and( + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, 'ACTIVE'), + ne(stageDocuments.buyerSystemStatus, "승인(DC)") + ) + ) + + return result + } + + private async sendDocumentInfo(documents: any[]) { + const shiDocuments: ShiDocumentInfo[] = documents.map(doc => ({ + PROJ_NO: doc.projectCode, + SHI_DOC_NO: doc.docNumber, + CATEGORY: "SHIP", + RESPONSIBLE_CD: "EVCP", + RESPONSIBLE: "eVCP System", + VNDR_CD: doc.vendorCode || "", + VNDR_NM: doc.vendorName || "", + DSN_SKL: "B3", + MIFP_CD: "", + MIFP_NM: "", + CG_EMPNO1: "", + CG_EMPNM1: "", + OWN_DOC_NO: doc.vendorDocNumber || doc.docNumber, + DSC: doc.title, + DOC_CLASS: "B3", + COMMENT: "", + STATUS: "ACTIVE", + CRTER: "EVCP_SYSTEM", + CRTE_DTM: new Date().toISOString(), + CHGR: "EVCP_SYSTEM", + CHG_DTM: new Date().toISOString() + })) + + const response = await fetch(`${this.baseUrl}/SetDwgInfo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(shiDocuments) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`도서 정보 전송 실패: ${response.statusText} - ${errorText}`) + } + + return response.json() + } + + private async sendScheduleInfo(documents: any[]) { + const schedules: ShiScheduleInfo[] = [] + + for (const doc of documents) { + for (const stage of doc.stages) { + if (stage.plan_date) { + schedules.push({ + PROJ_NO: doc.projectCode, + SHI_DOC_NO: doc.docNumber, + DDPKIND: "V", + SCHEDULE_TYPE: stage.stage_name, + BASELINE1: stage.plan_date ? new Date(stage.plan_date).toISOString() : null, + REVISED1: null, + FORECAST1: null, + ACTUAL1: stage.actual_date ? new Date(stage.actual_date).toISOString() : null, + BASELINE2: null, + REVISED2: null, + FORECAST2: null, + ACTUAL2: null, + CRTER: "EVCP_SYSTEM", + CRTE_DTM: new Date().toISOString(), + CHGR: "EVCP_SYSTEM", + CHG_DTM: new Date().toISOString() + }) + } + } + } + + if (schedules.length === 0) { + console.log("전송할 스케줄 정보가 없습니다.") + return + } + + const response = await fetch(`${this.baseUrl}/SetScheduleInfo`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(schedules) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`스케줄 정보 전송 실패: ${response.statusText} - ${errorText}`) + } + + return response.json() + } + + private async updateSyncStatus(documentIds: number[]) { + if (documentIds.length === 0) return + + await db + .update(stageDocuments) + .set({ + syncStatus: 'synced', + lastSyncedAt: new Date(), + syncError: null, + syncVersion: sql`sync_version + 1`, + lastModifiedBy: 'EVCP' + }) + .where(sql`id = ANY(${documentIds})`) + } + + private async updateSyncError(contractId: number, errorMessage: string) { + await db + .update(stageDocuments) + .set({ + syncStatus: 'error', + syncError: errorMessage, + lastModifiedBy: 'EVCP' + }) + .where( + and( + eq(stageDocuments.contractId, contractId), + eq(stageDocuments.status, 'ACTIVE') + ) + ) + } + + async pullDocumentStatus(contractId: number) { + try { + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!contract) { + throw new Error(`계약을 찾을 수 없습니다: ${contractId}`) + } + + const project = await db.query.projects.findFirst({ + where: eq(projects.id, contract.projectId), + }); + + if (!project) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${contract.projectId}`) + } + + const vendor = await db.query.vendors.findFirst({ + where: eq(vendors.id, contract.vendorId), + }); + + if (!vendor) { + throw new Error(`벤더를 찾을 수 없습니다: ${contract.vendorId}`) + } + + const shiDocuments = await this.fetchDocumentsFromSHI(project.code, { + VNDR_CD: vendor.vendorCode + }) + + if (!shiDocuments || shiDocuments.length === 0) { + return { + success: true, + message: "동기화할 문서가 없습니다.", + updatedCount: 0, + documents: [] + } + } + + const updateResults = await this.updateLocalDocuments(project.code, shiDocuments) + + return { + success: true, + message: `${updateResults.updatedCount}개 문서의 상태가 업데이트되었습니다.`, + updatedCount: updateResults.updatedCount, + newCount: updateResults.newCount, + documents: updateResults.documents + } + } catch (error) { + console.error("문서 상태 풀링 오류:", error) + throw error + } + } + + private async fetchDocumentsFromSHI( + projectCode: string, + filters?: { + SHI_DOC_NO?: string + CATEGORY?: string + VNDR_CD?: string + RESPONSIBLE_CD?: string + STATUS?: string + DOC_CLASS?: string + CRTE_DTM_FROM?: string + CRTE_DTM_TO?: string + CHG_DTM_FROM?: string + CHG_DTM_TO?: string + } + ): Promise<ShiDocumentResponse[]> { + const params = new URLSearchParams({ PROJ_NO: projectCode }) + + if (filters) { + Object.entries(filters).forEach(([key, value]) => { + if (value) params.append(key, value) + }) + } + + const url = `${this.baseUrl}/GetDwgInfo?${params.toString()}` + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/json' + } + }) + + if (!response.ok) { + throw new Error(`문서 조회 실패: ${response.statusText}`) + } + + const data: ShiApiResponse = await response.json() + + return data.GetDwgInfoResult || [] + } + + private async updateLocalDocuments( + projectCode: string, + shiDocuments: ShiDocumentResponse[] + ) { + let updatedCount = 0 + let newCount = 0 + const updatedDocuments: any[] = [] + + const project = await db.query.projects.findFirst({ + where: eq(projects.code, projectCode) + }) + + if (!project) { + throw new Error(`프로젝트를 찾을 수 없습니다: ${projectCode}`) + } + + for (const shiDoc of shiDocuments) { + const localDoc = await db.query.stageDocuments.findFirst({ + where: and( + eq(stageDocuments.projectId, project.id), + eq(stageDocuments.docNumber, shiDoc.SHI_DOC_NO) + ) + }) + + if (localDoc) { + if ( + localDoc.buyerSystemStatus !== shiDoc.STATUS || + localDoc.buyerSystemComment !== shiDoc.COMMENT + ) { + await db + .update(stageDocuments) + .set({ + buyerSystemStatus: shiDoc.STATUS, + buyerSystemComment: shiDoc.COMMENT, + lastSyncedAt: new Date(), + syncStatus: 'synced', + syncError: null, + lastModifiedBy: 'BUYER_SYSTEM', + syncVersion: sql`sync_version + 1` + }) + .where(eq(stageDocuments.id, localDoc.id)) + + updatedCount++ + updatedDocuments.push({ + docNumber: shiDoc.SHI_DOC_NO, + title: shiDoc.DSC || localDoc.title, + status: shiDoc.STATUS, + comment: shiDoc.COMMENT, + action: 'updated' + }) + } + } else { + console.log(`SHI에만 존재하는 문서: ${shiDoc.SHI_DOC_NO}`) + newCount++ + updatedDocuments.push({ + docNumber: shiDoc.SHI_DOC_NO, + title: shiDoc.DSC || 'N/A', + status: shiDoc.STATUS, + comment: shiDoc.COMMENT, + action: 'new_in_shi' + }) + } + } + + return { + updatedCount, + newCount, + documents: updatedDocuments + } + } + + async getSyncStatus(contractId: number) { + const documents = await db + .select({ + docNumber: stageDocuments.docNumber, + title: stageDocuments.title, + syncStatus: stageDocuments.syncStatus, + lastSyncedAt: stageDocuments.lastSyncedAt, + syncError: stageDocuments.syncError, + buyerSystemStatus: stageDocuments.buyerSystemStatus, + buyerSystemComment: stageDocuments.buyerSystemComment + }) + .from(stageDocuments) + .where(eq(stageDocuments.contractId, contractId)) + + return documents + } + + /** + * 스테이지 제출 건들의 파일을 SHI 구매자 시스템으로 동기화 + * @param submissionIds 제출 ID 배열 + */ + async syncSubmissionsToSHI(submissionIds: number[]) { + const results = { + totalCount: submissionIds.length, + successCount: 0, + failedCount: 0, + details: [] as any[] + } + + for (const submissionId of submissionIds) { + try { + const result = await this.syncSingleSubmission(submissionId) + if (result.success) { + results.successCount++ + } else { + results.failedCount++ + } + results.details.push(result) + } catch (error) { + results.failedCount++ + results.details.push({ + submissionId, + success: false, + error: error instanceof Error ? error.message : "Unknown error" + }) + } + } + + return results + } + + /** + * 단일 제출 건 동기화 + */ + private async syncSingleSubmission(submissionId: number) { + try { + // 1. 제출 정보 조회 (프로젝트, 문서, 스테이지, 파일 정보 포함) + const submissionInfo = await this.getSubmissionFullInfo(submissionId) + + if (!submissionInfo) { + throw new Error(`제출 정보를 찾을 수 없습니다: ${submissionId}`) + } + + // 2. 동기화 시작 상태 업데이트 + await this.updateSubmissionSyncStatus(submissionId, 'syncing') + + // 3. 첨부파일들과 실제 파일 내용을 준비 + const filesWithContent = await this.prepareFilesWithContent(submissionInfo) + + if (filesWithContent.length === 0) { + await this.updateSubmissionSyncStatus(submissionId, 'synced', '전송할 파일이 없습니다') + return { + submissionId, + success: true, + message: "전송할 파일이 없습니다" + } + } + + // 4. SaveInBoxList API 호출하여 네트워크 경로 받기 + const response = await this.sendToInBox(filesWithContent) + + // 5. 응답받은 네트워크 경로에 파일 저장 + if (response.SaveInBoxListResult.success && response.SaveInBoxListResult.files) { + await this.saveFilesToNetworkPaths(filesWithContent, response.SaveInBoxListResult.files) + + // 6. 동기화 결과 업데이트 + await this.updateSubmissionSyncStatus(submissionId, 'synced', null, { + syncedFilesCount: filesWithContent.length, + buyerSystemStatus: 'SYNCED' + }) + + // 개별 파일 상태 업데이트 + await this.updateAttachmentsSyncStatus( + submissionInfo.attachments.map(a => a.id), + 'synced' + ) + + return { + submissionId, + success: true, + message: response.SaveInBoxListResult.message, + syncedFiles: filesWithContent.length + } + } else { + throw new Error(response.SaveInBoxListResult.message) + } + } catch (error) { + await this.updateSubmissionSyncStatus( + submissionId, + 'failed', + error instanceof Error ? error.message : '알 수 없는 오류' + ) + + throw error + } + } + + /** + * 제출 정보 조회 (관련 정보 포함) + */ + private async getSubmissionFullInfo(submissionId: number) { + const result = await db + .select({ + submission: stageSubmissions, + stage: stageIssueStages, + document: stageDocuments, + project: projects, + vendor: vendors + }) + .from(stageSubmissions) + .innerJoin(stageIssueStages, eq(stageSubmissions.stageId, stageIssueStages.id)) + .innerJoin(stageDocuments, eq(stageSubmissions.documentId, stageDocuments.id)) + .innerJoin(projects, eq(stageDocuments.projectId, projects.id)) + .leftJoin(vendors, eq(stageDocuments.vendorId, vendors.id)) + .where(eq(stageSubmissions.id, submissionId)) + .limit(1) + + if (result.length === 0) return null + + // 첨부파일 조회 - 파일 경로 포함 + const attachments = await db + .select() + .from(stageSubmissionAttachments) + .where( + and( + eq(stageSubmissionAttachments.submissionId, submissionId), + eq(stageSubmissionAttachments.status, 'ACTIVE') + ) + ) + + return { + ...result[0], + attachments + } + } + + /** + * 파일 내용과 함께 InBox 파일 정보 준비 + */ + private async prepareFilesWithContent(submissionInfo: any): Promise<Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>> { + const filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }> = [] + + for (const attachment of submissionInfo.attachments) { + try { + // 파일 경로 결정 (storagePath 또는 storageUrl 사용) + const filePath = attachment.storagePath || attachment.storageUrl + + if (!filePath) { + console.warn(`첨부파일 ${attachment.id}의 경로를 찾을 수 없습니다.`) + continue + } + + // 전체 경로 생성 + const fullPath = path.isAbsolute(filePath) + ? filePath + : path.join(this.localStoragePath, filePath) + + // 파일 읽기 + const fileBuffer = await fs.readFile(fullPath) + + // 파일 정보 생성 + const fileInfo: InBoxFileInfo & { fileBuffer: Buffer, attachment: any } = { + PROJ_NO: submissionInfo.project.code, + SHI_DOC_NO: submissionInfo.document.docNumber, + STAGE_NAME: submissionInfo.stage.stageName, + REVISION_NO: String(submissionInfo.submission.revisionNumber), + VNDR_CD: submissionInfo.vendor?.vendorCode || '', + VNDR_NM: submissionInfo.vendor?.vendorName || '', + FILE_NAME: attachment.fileName, + FILE_SIZE: fileBuffer.length, // 실제 파일 크기 사용 + CONTENT_TYPE: attachment.mimeType || 'application/octet-stream', + UPLOAD_DATE: new Date().toISOString(), + UPLOADED_BY: submissionInfo.submission.submittedBy, + STATUS: 'PENDING', + COMMENT: `Revision ${submissionInfo.submission.revisionNumber} - ${submissionInfo.stage.stageName}`, + fileBuffer: fileBuffer, + attachment: attachment + } + + filesWithContent.push(fileInfo) + } catch (error) { + console.error(`파일 읽기 실패: ${attachment.fileName}`, error) + // 파일 읽기 실패 시 계속 진행 + continue + } + } + + return filesWithContent + } + + /** + * SaveInBoxList API 호출 (파일 메타데이터만 전송) + */ + private async sendToInBox(files: Array<InBoxFileInfo & { fileBuffer: Buffer }>): Promise<SaveInBoxListResponse> { + // fileBuffer를 제외한 메타데이터만 전송 + const fileMetadata = files.map(({ fileBuffer, attachment, ...metadata }) => metadata) + + const request = { files: fileMetadata } + + const response = await fetch(`${this.ddcUrl}/SaveInBoxList`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(request) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`InBox 전송 실패: ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + + // 응답 구조 확인 및 처리 + if (!data.SaveInBoxListResult) { + return { + SaveInBoxListResult: { + success: true, + message: "전송 완료", + processedCount: files.length, + files: files.map(f => ({ + fileName: f.FILE_NAME, + networkPath: `\\\\network\\share\\${f.PROJ_NO}\\${f.SHI_DOC_NO}\\${f.FILE_NAME}`, + status: 'READY' + })) + } + } + } + + return data + } + + /** + * 네트워크 경로에 파일 저장 + */ + private async saveFilesToNetworkPaths( + filesWithContent: Array<InBoxFileInfo & { fileBuffer: Buffer, attachment: any }>, + networkPathInfo: Array<{ fileName: string, networkPath: string, status: string }> + ) { + for (const fileInfo of filesWithContent) { + const pathInfo = networkPathInfo.find(p => p.fileName === fileInfo.FILE_NAME) + + if (!pathInfo || !pathInfo.networkPath) { + console.error(`네트워크 경로를 찾을 수 없습니다: ${fileInfo.FILE_NAME}`) + continue + } + + try { + // 네트워크 경로에 파일 저장 + // Windows 네트워크 경로인 경우 처리 + let targetPath = pathInfo.networkPath + + // Windows 네트워크 경로를 Node.js가 이해할 수 있는 형식으로 변환 + if (process.platform === 'win32' && targetPath.startsWith('\\\\')) { + // 그대로 사용 + } else if (process.platform !== 'win32' && targetPath.startsWith('\\\\')) { + // Linux/Mac에서는 SMB 마운트 경로로 변환 필요 + // 예: \\\\server\\share -> /mnt/server/share + targetPath = targetPath.replace(/\\\\/g, '/mnt/').replace(/\\/g, '/') + } + + // 디렉토리 생성 (없는 경우) + const targetDir = path.dirname(targetPath) + await fs.mkdir(targetDir, { recursive: true }) + + // 파일 저장 + await fs.writeFile(targetPath, fileInfo.fileBuffer) + + console.log(`파일 저장 완료: ${fileInfo.FILE_NAME} -> ${targetPath}`) + + // DB에 네트워크 경로 업데이트 + await db + .update(stageSubmissionAttachments) + .set({ + buyerSystemUrl: pathInfo.networkPath, + buyerSystemStatus: 'UPLOADED', + lastModifiedBy: 'EVCP' + }) + .where(eq(stageSubmissionAttachments.id, fileInfo.attachment.id)) + + } catch (error) { + console.error(`파일 저장 실패: ${fileInfo.FILE_NAME}`, error) + // 개별 파일 실패는 전체 프로세스를 중단하지 않음 + } + } + } + + /** + * 제출 동기화 상태 업데이트 + */ + private async updateSubmissionSyncStatus( + submissionId: number, + status: string, + error?: string | null, + additionalData?: any + ) { + const updateData: any = { + syncStatus: status, + lastSyncedAt: new Date(), + syncError: error, + lastModifiedBy: 'EVCP', + ...additionalData + } + + if (status === 'failed') { + updateData.syncRetryCount = sql`sync_retry_count + 1` + updateData.nextRetryAt = new Date(Date.now() + 30 * 60 * 1000) // 30분 후 재시도 + } + + await db + .update(stageSubmissions) + .set(updateData) + .where(eq(stageSubmissions.id, submissionId)) + } + + /** + * 첨부파일 동기화 상태 업데이트 + */ + private async updateAttachmentsSyncStatus( + attachmentIds: number[], + status: string + ) { + if (attachmentIds.length === 0) return + + await db + .update(stageSubmissionAttachments) + .set({ + syncStatus: status, + syncCompletedAt: status === 'synced' ? new Date() : null, + buyerSystemStatus: status === 'synced' ? 'UPLOADED' : 'PENDING', + lastModifiedBy: 'EVCP' + }) + .where(sql`id = ANY(${attachmentIds})`) + } + + /** + * 동기화 재시도 (실패한 건들) + */ + async retrySyncFailedSubmissions(contractId?: number) { + const conditions = [ + eq(stageSubmissions.syncStatus, 'failed'), + sql`next_retry_at <= NOW()` + ] + + if (contractId) { + const documentIds = await db + .select({ id: stageDocuments.id }) + .from(stageDocuments) + .where(eq(stageDocuments.contractId, contractId)) + + if (documentIds.length > 0) { + conditions.push( + sql`document_id = ANY(${documentIds.map(d => d.id)})` + ) + } + } + + const failedSubmissions = await db + .select({ id: stageSubmissions.id }) + .from(stageSubmissions) + .where(and(...conditions)) + .limit(10) // 한 번에 최대 10개씩 재시도 + + if (failedSubmissions.length === 0) { + return { + success: true, + message: "재시도할 제출 건이 없습니다.", + retryCount: 0 + } + } + + const submissionIds = failedSubmissions.map(s => s.id) + const results = await this.syncSubmissionsToSHI(submissionIds) + + return { + success: true, + message: `${results.successCount}/${results.totalCount}개 제출 건 재시도 완료`, + ...results + } + } +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/columns.tsx b/lib/vendor-document-list/plant/upload/columns.tsx new file mode 100644 index 00000000..c0f17afc --- /dev/null +++ b/lib/vendor-document-list/plant/upload/columns.tsx @@ -0,0 +1,379 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { StageSubmissionView } from "@/db/schema" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Ellipsis, + Upload, + Eye, + RefreshCw, + CheckCircle2, + XCircle, + AlertCircle, + Clock +} from "lucide-react" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageSubmissionView> | null>> +} + +export function getColumns({ + setRowAction, +}: GetColumnsProps): ColumnDef<StageSubmissionView>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Doc Number" /> + ), + cell: ({ row }) => { + const vendorDocNumber = row.original.vendorDocNumber + return ( + <div className="space-y-1"> + <div className="font-medium">{row.getValue("docNumber")}</div> + {vendorDocNumber && ( + <div className="text-xs text-muted-foreground">{vendorDocNumber}</div> + )} + </div> + ) + }, + size: 150, + }, + { + accessorKey: "documentTitle", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Title" /> + ), + cell: ({ row }) => ( + <div className="max-w-[300px] truncate" title={row.getValue("documentTitle")}> + {row.getValue("documentTitle")} + </div> + ), + size: 250, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => ( + <Badge variant="outline">{row.getValue("projectCode")}</Badge> + ), + size: 100, + }, + { + accessorKey: "stageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Stage" /> + ), + cell: ({ row }) => { + const stageName = row.getValue("stageName") as string + const stageStatus = row.original.stageStatus + const stageOrder = row.original.stageOrder + + return ( + <div className="space-y-1"> + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {stageOrder ? `#${stageOrder}` : ""} + </Badge> + <span className="text-sm">{stageName}</span> + </div> + {stageStatus && ( + <Badge + variant={ + stageStatus === "COMPLETED" ? "success" : + stageStatus === "IN_PROGRESS" ? "default" : + stageStatus === "REJECTED" ? "destructive" : + "secondary" + } + className="text-xs" + > + {stageStatus} + </Badge> + )} + </div> + ) + }, + size: 200, + }, + { + accessorKey: "stagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Due Date" /> + ), + cell: ({ row }) => { + const planDate = row.getValue("stagePlanDate") as Date | null + const isOverdue = row.original.isOverdue + const daysUntilDue = row.original.daysUntilDue + + if (!planDate) return <span className="text-muted-foreground">-</span> + + return ( + <div className="space-y-1"> + <div className={isOverdue ? "text-destructive font-medium" : ""}> + {formatDate(planDate)} + </div> + {daysUntilDue !== null && ( + <div className="text-xs"> + {isOverdue ? ( + <Badge variant="destructive" className="gap-1"> + <AlertCircle className="h-3 w-3" /> + {Math.abs(daysUntilDue)} days overdue + </Badge> + ) : daysUntilDue === 0 ? ( + <Badge variant="warning" className="gap-1"> + <Clock className="h-3 w-3" /> + Due today + </Badge> + ) : ( + <span className="text-muted-foreground"> + {daysUntilDue} days remaining + </span> + )} + </div> + )} + </div> + ) + }, + size: 150, + }, + { + accessorKey: "latestSubmissionStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Submission Status" /> + ), + cell: ({ row }) => { + const status = row.getValue("latestSubmissionStatus") as string | null + const reviewStatus = row.original.latestReviewStatus + const revisionNumber = row.original.latestRevisionNumber + const revisionCode = row.original.latestRevisionCode + + if (!status) { + return ( + <Badge variant="outline" className="gap-1"> + <AlertCircle className="h-3 w-3" /> + Not submitted + </Badge> + ) + } + + return ( + <div className="space-y-1"> + <Badge + variant={ + reviewStatus === "APPROVED" ? "success" : + reviewStatus === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : + "secondary" + } + > + {reviewStatus || status} + </Badge> + {revisionCode !== null &&( + <div className="text-xs text-muted-foreground"> + {revisionCode} + </div> + )} + </div> + ) + }, + size: 150, + }, + { + id: "syncStatus", + accessorKey: "latestSyncStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Sync Status" /> + ), + cell: ({ row }) => { + const syncStatus = row.getValue("latestSyncStatus") as string | null + const syncProgress = row.original.syncProgress + const requiresSync = row.original.requiresSync + + if (!syncStatus || syncStatus === "pending") { + if (requiresSync) { + return ( + <Badge variant="outline" className="gap-1"> + <Clock className="h-3 w-3" /> + Pending + </Badge> + ) + } + return <span className="text-muted-foreground">-</span> + } + + return ( + <div className="space-y-2"> + <Badge + variant={ + syncStatus === "synced" ? "success" : + syncStatus === "failed" ? "destructive" : + syncStatus === "syncing" ? "default" : + "secondary" + } + className="gap-1" + > + {syncStatus === "syncing" && <RefreshCw className="h-3 w-3 animate-spin" />} + {syncStatus === "synced" && <CheckCircle2 className="h-3 w-3" />} + {syncStatus === "failed" && <XCircle className="h-3 w-3" />} + {syncStatus} + </Badge> + {syncProgress !== null && syncProgress !== undefined && syncStatus === "syncing" && ( + <Progress value={syncProgress} className="h-1.5 w-20" /> + )} + </div> + ) + }, + size: 120, + }, + { + accessorKey: "totalFiles", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Files" /> + ), + cell: ({ row }) => { + const totalFiles = row.getValue("totalFiles") as number + const syncedFiles = row.original.syncedFilesCount + + if (!totalFiles) return <span className="text-muted-foreground">0</span> + + return ( + <div className="text-sm"> + {syncedFiles !== null && syncedFiles !== undefined ? ( + <span>{syncedFiles}/{totalFiles}</span> + ) : ( + <span>{totalFiles}</span> + )} + </div> + ) + }, + size: 80, + }, + // { + // accessorKey: "vendorName", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Vendor" /> + // ), + // cell: ({ row }) => { + // const vendorName = row.getValue("vendorName") as string + // const vendorCode = row.original.vendorCode + + // return ( + // <div className="space-y-1"> + // <div className="text-sm">{vendorName}</div> + // {vendorCode && ( + // <div className="text-xs text-muted-foreground">{vendorCode}</div> + // )} + // </div> + // ) + // }, + // size: 150, + // }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const requiresSubmission = row.original.requiresSubmission + const requiresSync = row.original.requiresSync + const latestSubmissionId = row.original.latestSubmissionId + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 p-0" + > + <Ellipsis className="size-4" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + {requiresSubmission && ( + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "upload" })} + className="gap-2" + > + <Upload className="h-4 w-4" /> + Upload Documents + </DropdownMenuItem> + )} + + {latestSubmissionId && ( + <> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "view" })} + className="gap-2" + > + <Eye className="h-4 w-4" /> + View Submission + </DropdownMenuItem> + + {requiresSync && ( + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "sync" })} + className="gap-2" + > + <RefreshCw className="h-4 w-4" /> + Retry Sync + </DropdownMenuItem> + )} + </> + )} + + <DropdownMenuSeparator /> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "history" })} + className="gap-2" + > + <Clock className="h-4 w-4" /> + View History + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/history-dialog.tsx b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx new file mode 100644 index 00000000..9c4f160b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/history-dialog.tsx @@ -0,0 +1,144 @@ +// lib/vendor-document-list/plant/upload/components/history-dialog.tsx +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + CheckCircle2, + XCircle, + Clock, + FileText, + User, + Calendar, + AlertCircle +} from "lucide-react" +import { StageSubmissionView } from "@/db/schema" +import { formatDateTime } from "@/lib/utils" + +interface HistoryDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView +} + +export function HistoryDialog({ + open, + onOpenChange, + submission +}: HistoryDialogProps) { + const history = submission.submissionHistory || [] + + const getStatusIcon = (status: string, reviewStatus?: string) => { + if (reviewStatus === "APPROVED") { + return <CheckCircle2 className="h-4 w-4 text-success" /> + } + if (reviewStatus === "REJECTED") { + return <XCircle className="h-4 w-4 text-destructive" /> + } + if (status === "SUBMITTED") { + return <Clock className="h-4 w-4 text-primary" /> + } + return <AlertCircle className="h-4 w-4 text-muted-foreground" /> + } + + const getStatusBadge = (status: string, reviewStatus?: string) => { + const variant = reviewStatus === "APPROVED" ? "success" : + reviewStatus === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return ( + <Badge variant={variant}> + {reviewStatus || status} + </Badge> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-3xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>Submission History</DialogTitle> + <DialogDescription> + View all submission history for this stage + </DialogDescription> + </DialogHeader> + + {/* Document Info */} + <div className="grid gap-2 p-4 bg-muted rounded-lg"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm font-medium">{submission.docNumber}</span> + <span className="text-sm text-muted-foreground"> + - {submission.documentTitle} + </span> + </div> + <Badge variant="outline">{submission.stageName}</Badge> + </div> + </div> + + {/* History Timeline */} + <ScrollArea className="h-[400px] pr-4"> + {history.length === 0 ? ( + <div className="text-center py-8 text-muted-foreground"> + No submission history available + </div> + ) : ( + <div className="space-y-4"> + {history.map((item, index) => ( + <div key={item.submissionId} className="relative"> + {/* Timeline line */} + {index < history.length - 1 && ( + <div className="absolute left-5 top-10 bottom-0 w-0.5 bg-border" /> + )} + + {/* Timeline item */} + <div className="flex gap-4"> + <div className="flex-shrink-0 w-10 h-10 rounded-full bg-background border-2 border-border flex items-center justify-center"> + {getStatusIcon(item.status, item.reviewStatus)} + </div> + + <div className="flex-1 pb-4"> + <div className="flex items-center gap-2 mb-2"> + <span className="font-medium">Revision {item.revisionNumber}</span> + {getStatusBadge(item.status, item.reviewStatus)} + {item.syncStatus && ( + <Badge variant="outline" className="text-xs"> + Sync: {item.syncStatus} + </Badge> + )} + </div> + + <div className="grid gap-1 text-sm text-muted-foreground"> + <div className="flex items-center gap-2"> + <User className="h-3 w-3" /> + <span>{item.submittedBy}</span> + </div> + <div className="flex items-center gap-2"> + <Calendar className="h-3 w-3" /> + <span>{formatDateTime(new Date(item.submittedAt))}</span> + </div> + <div className="flex items-center gap-2"> + <FileText className="h-3 w-3" /> + <span>{item.fileCount} file(s)</span> + </div> + </div> + </div> + </div> + </div> + ))} + </div> + )} + </ScrollArea> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx new file mode 100644 index 00000000..81a1d486 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx @@ -0,0 +1,492 @@ +// lib/vendor-document-list/plant/upload/components/multi-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useCallback } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + CheckCircle2, + AlertCircle, + Loader2, + CloudUpload, + FileWarning +} from "lucide-react" +import { toast } from "sonner" +import { validateFiles } from "../../document-stages-service" +import { parseFileName, ParsedFileName } from "../util/filie-parser" + +interface FileWithMetadata { + file: File + parsed: ParsedFileName + matched?: { + documentId: number + stageId: number + documentTitle: string + currentRevision?: string // number에서 string으로 변경 + } + status: 'pending' | 'validating' | 'uploading' | 'success' | 'error' + error?: string + progress?: number +} + +interface MultiUploadDialogProps { + projectId: number + // projectCode: string + onUploadComplete?: () => void +} + + +export function MultiUploadDialog({ + projectId, + // projectCode, + onUploadComplete +}: MultiUploadDialogProps) { + const [open, setOpen] = useState(false) + const [files, setFiles] = useState<FileWithMetadata[]>([]) + const [isValidating, setIsValidating] = useState(false) + const [isUploading, setIsUploading] = useState(false) + + // 디버깅용 로그 + console.log("Current files:", files) + + // 파일 추가 핸들러 - onChange 이벤트용 + const handleFilesChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { + const fileList = e.target.files + console.log("Files selected via input:", fileList) + + if (fileList && fileList.length > 0) { + handleFilesAdded(Array.from(fileList)) + } + }, []) + + // 파일 추가 핸들러 - 공통 + const handleFilesAdded = useCallback(async (newFiles: File[]) => { + console.log("handleFilesAdded called with:", newFiles) + + if (!newFiles || newFiles.length === 0) { + console.log("No files provided") + return + } + + const processedFiles: FileWithMetadata[] = newFiles.map(file => { + const parsed = parseFileName(file.name) + console.log(`Parsed ${file.name}:`, parsed) + + return { + file, + parsed, + status: 'pending' as const + } + }) + + setFiles(prev => { + const updated = [...prev, ...processedFiles] + console.log("Updated files state:", updated) + return updated + }) + + // 유효한 파일들만 검증 + const validFiles = processedFiles.filter(f => f.parsed.isValid) + console.log("Valid files for validation:", validFiles) + + if (validFiles.length > 0) { + await validateFilesWithServer(validFiles) + } + }, []) + + // 서버 검증 + const validateFilesWithServer = async (filesToValidate: FileWithMetadata[]) => { + console.log("Starting validation for:", filesToValidate) + setIsValidating(true) + + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'validating' as const } + : file + )) + + try { + const validationData = filesToValidate.map(f => ({ + projectId, // projectCode 대신 projectId 사용 + docNumber: f.parsed.docNumber, + stageName: f.parsed.stageName, + revision: f.parsed.revision + }))s + + console.log("Sending validation data:", validationData) + const results = await validateFiles(validationData) + console.log("Validation results:", results) + + // 매칭 결과 업데이트 - projectCode 체크 제거 + setFiles(prev => prev.map(file => { + const result = results.find(r => + r.docNumber === file.parsed.docNumber && + r.stageName === file.parsed.stageName + ) + + if (result && result.matched) { + console.log(`File ${file.file.name} matched:`, result.matched) + return { + ...file, + matched: result.matched, + status: 'pending' as const + } + } + return { ...file, status: 'pending' as const } + })) + } catch (error) { + console.error("Validation error:", error) + toast.error("Failed to validate files") + setFiles(prev => prev.map(file => + filesToValidate.some(f => f.file === file.file) + ? { ...file, status: 'error' as const, error: 'Validation failed' } + : file + )) + } finally { + setIsValidating(false) + } + } + // Drag and Drop 핸들러 + const handleDrop = useCallback((e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault() + e.stopPropagation() + + const droppedFiles = Array.from(e.dataTransfer.files) + console.log("Files dropped:", droppedFiles) + + if (droppedFiles.length > 0) { + handleFilesAdded(droppedFiles) + } + }, [handleFilesAdded]) + + const handleDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => { + e.preventDefault() + e.stopPropagation() + }, []) + + // 파일 제거 + const removeFile = (index: number) => { + console.log("Removing file at index:", index) + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 실행 + const handleUpload = async () => { + const uploadableFiles = files.filter(f => f.parsed.isValid && f.matched) + console.log("Files to upload:", uploadableFiles) + + if (uploadableFiles.length === 0) { + toast.error("No valid files to upload") + return + } + + setIsUploading(true) + + // 업로드 중 상태로 변경 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'uploading' as const } + : file + )) + + try { + const formData = new FormData() + + uploadableFiles.forEach((fileData, index) => { + formData.append(`files`, fileData.file) + formData.append(`metadata[${index}]`, JSON.stringify({ + documentId: fileData.matched!.documentId, + stageId: fileData.matched!.stageId, + revision: fileData.parsed.revision, + originalName: fileData.file.name + })) + }) + + console.log("Sending upload request") + const response = await fetch('/api/stage-submissions/bulk-upload', { + method: 'POST', + body: formData + }) + + if (!response.ok) { + const error = await response.text() + console.error("Upload failed:", error) + throw new Error('Upload failed') + } + + const result = await response.json() + console.log("Upload result:", result) + + // 성공 상태 업데이트 + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'success' as const } + : file + )) + + toast.success(`Successfully uploaded ${result.uploaded} files`) + + setTimeout(() => { + setOpen(false) + setFiles([]) + onUploadComplete?.() + }, 2000) + + } catch (error) { + console.error("Upload error:", error) + toast.error("Upload failed") + + setFiles(prev => prev.map(file => + uploadableFiles.includes(file) + ? { ...file, status: 'error' as const, error: 'Upload failed' } + : file + )) + } finally { + setIsUploading(false) + } + } + + // 통계 계산 + const stats = { + total: files.length, + valid: files.filter(f => f.parsed.isValid).length, + matched: files.filter(f => f.matched).length, + ready: files.filter(f => f.parsed.isValid && f.matched).length, + totalSize: files.reduce((acc, f) => acc + f.file.size, 0) + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + // 파일별 상태 아이콘 + const getStatusIcon = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return <FileWarning className="h-4 w-4 text-destructive" /> + } + + switch (fileData.status) { + case 'validating': + return <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + case 'uploading': + return <Loader2 className="h-4 w-4 animate-spin text-primary" /> + case 'success': + return <CheckCircle2 className="h-4 w-4 text-success" /> + case 'error': + return <AlertCircle className="h-4 w-4 text-destructive" /> + default: + if (fileData.matched) { + return <CheckCircle2 className="h-4 w-4 text-success" /> + } else { + return <AlertCircle className="h-4 w-4 text-warning" /> + } + } + } + + // 파일별 상태 설명 + const getStatusDescription = (fileData: FileWithMetadata) => { + if (!fileData.parsed.isValid) { + return fileData.parsed.error || "Invalid format" + } + + switch (fileData.status) { + case 'validating': + return "Checking..." + case 'uploading': + return "Uploading..." + case 'success': + return "Uploaded" + case 'error': + return fileData.error || "Failed" + default: + if (fileData.matched) { + // projectCode 제거 + return `${fileData.parsed.docNumber}_${fileData.parsed.stageName}` + } else { + return "Document not found in system" + } + } + } + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogTrigger asChild> + <Button variant="outline" className="gap-2"> + <CloudUpload className="h-4 w-4" /> + Multi-Upload + </Button> + </DialogTrigger> + <DialogContent className="max-w-5xl max-h-[90vh] flex flex-col"> + <DialogHeader> + <DialogTitle>Bulk Document Upload</DialogTitle> + <DialogDescription> + Upload multiple files at once. Files should be named as: DocNumber_StageName_Revision.ext + </DialogDescription> + </DialogHeader> + + {/* Custom Dropzone with input */} + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors cursor-pointer" + onDrop={handleDrop} + onDragOver={handleDragOver} + onClick={() => document.getElementById('file-upload')?.click()} + > + <input + id="file-upload" + type="file" + multiple + className="hidden" + onChange={handleFilesChange} + accept="*/*" + /> + <Upload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> + <p className="text-lg font-medium">Drop files here or click to browse</p> + <p className="text-sm text-gray-500 mt-1"> + Maximum 10GB total • Format: DocNumber_StageName_Revision.ext + </p> + </div> + + {/* Stats */} + {files.length > 0 && ( + <div className="flex gap-2 flex-wrap"> + <Badge variant="outline">Total: {stats.total}</Badge> + <Badge variant={stats.valid === stats.total ? "success" : "secondary"}> + Valid Format: {stats.valid} + </Badge> + <Badge variant={stats.matched > 0 ? "success" : "secondary"}> + Matched: {stats.matched} + </Badge> + <Badge variant={stats.ready > 0 ? "default" : "outline"}> + Ready: {stats.ready} + </Badge> + <Badge variant="outline"> + Size: {formatFileSize(stats.totalSize)} + </Badge> + </div> + )} + + {/* File List */} + {files.length > 0 && ( + <div className="flex-1 rounded-md border overflow-y-auto" style={{ minHeight: 200, maxHeight: 400 }}> + <FileList className="p-4"> + <FileListHeader> + <div className="text-sm font-medium">Files ({files.length})</div> + </FileListHeader> + + {files.map((fileData, index) => ( + <FileListItem key={index}> + <FileListIcon> + {getStatusIcon(fileData)} + </FileListIcon> + + <FileListInfo> + <FileListName>{fileData.file.name}</FileListName> + <FileListDescription> + {getStatusDescription(fileData)} + </FileListDescription> + </FileListInfo> + + <FileListSize> + {fileData.file.size} + </FileListSize> + + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={(e) => { + e.stopPropagation() + removeFile(index) + }} + disabled={isUploading || fileData.status === 'uploading'} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + </div> + )} + {/* Error Alert */} + {files.filter(f => !f.parsed.isValid).length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + {files.filter(f => !f.parsed.isValid).length} file(s) have invalid naming format. + Expected: ProjectCode_DocNumber_StageName_Rev0.ext + </AlertDescription> + </Alert> + )} + + <DialogFooter> + <Button + variant="outline" + onClick={() => { + setOpen(false) + setFiles([]) + }} + disabled={isUploading} + > + Cancel + </Button> + <Button + onClick={handleUpload} + disabled={stats.ready === 0 || isUploading || isValidating} + className="gap-2" + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Uploading {stats.ready} files... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Upload {stats.ready} file{stats.ready !== 1 ? 's' : ''} + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/project-filter.tsx b/lib/vendor-document-list/plant/upload/components/project-filter.tsx new file mode 100644 index 00000000..33c2819b --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/project-filter.tsx @@ -0,0 +1,109 @@ +// lib/vendor-document-list/plant/upload/components/project-filter.tsx +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown, Building2 } from "lucide-react" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" + +interface Project { + id: number + code: string +} + +interface ProjectFilterProps { + projects: Project[] + value?: number | null + onValueChange: (value: number | null) => void +} + +export function ProjectFilter({ projects, value, onValueChange }: ProjectFilterProps) { + const [open, setOpen] = React.useState(false) + + const selectedProject = projects.find(p => p.id === value) + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className="w-[250px] justify-between" + > + <div className="flex items-center gap-2 truncate"> + <Building2 className="h-4 w-4 shrink-0 text-muted-foreground" /> + {selectedProject ? ( + <> + <span className="truncate">{selectedProject.code}</span> + <Badge variant="secondary" className="ml-1"> + Selected + </Badge> + </> + ) : ( + <span className="text-muted-foreground">All Projects</span> + )} + </div> + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[250px] p-0"> + <Command> + <CommandInput placeholder="Search project..." /> + <CommandList> + <CommandEmpty>No project found.</CommandEmpty> + <CommandGroup> + <CommandItem + value="" + onSelect={() => { + onValueChange(null) + setOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + value === null ? "opacity-100" : "opacity-0" + )} + /> + <span className="text-muted-foreground">All Projects</span> + </CommandItem> + {projects.map((project) => ( + <CommandItem + key={project.id} + value={project.code} + onSelect={() => { + onValueChange(project.id) + setOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + value === project.id ? "opacity-100" : "opacity-0" + )} + /> + {project.code} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx new file mode 100644 index 00000000..a33a7160 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx @@ -0,0 +1,265 @@ +// lib/vendor-document-list/plant/upload/components/single-upload-dialog.tsx +"use client" + +import * as React from "react" +import { useState } 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 { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + FileList, + FileListAction, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { + Upload, + X, + FileIcon, + Loader2, + AlertCircle +} from "lucide-react" +import { toast } from "sonner" +import { StageSubmissionView } from "@/db/schema" + +interface SingleUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView + onUploadComplete?: () => void +} + +export function SingleUploadDialog({ + open, + onOpenChange, + submission, + onUploadComplete +}: SingleUploadDialogProps) { + const [files, setFiles] = useState<File[]>([]) + const [description, setDescription] = useState("") + const [isUploading, setIsUploading] = useState(false) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일 선택 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const fileList = e.target.files + if (fileList) { + setFiles(Array.from(fileList)) + } + } + + // 파일 제거 + const removeFile = (index: number) => { + setFiles(prev => prev.filter((_, i) => i !== index)) + } + + // 업로드 처리 + const handleUpload = async () => { + if (files.length === 0) { + toast.error("Please select files to upload") + return + } + + setIsUploading(true) + + try { + const formData = new FormData() + + files.forEach((file) => { + formData.append("files", file) + }) + + formData.append("documentId", submission.documentId.toString()) + formData.append("stageId", submission.stageId!.toString()) + formData.append("description", description) + + // 현재 리비전 + 1 + const nextRevision = (submission.latestRevisionNumber || 0) + 1 + formData.append("revision", nextRevision.toString()) + + const response = await fetch("/api/stage-submissions/upload", { + method: "POST", + body: formData, + }) + + if (!response.ok) { + throw new Error("Upload failed") + } + + const result = await response.json() + toast.success(`Successfully uploaded ${files.length} file(s)`) + + // 초기화 및 닫기 + setFiles([]) + setDescription("") + onOpenChange(false) + onUploadComplete?.() + + } catch (error) { + console.error("Upload error:", error) + toast.error("Failed to upload files") + } finally { + setIsUploading(false) + } + } + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return '0 Bytes' + const k = 1024 + const sizes = ['Bytes', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i] + } + + const totalSize = files.reduce((acc, file) => acc + file.size, 0) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-2xl"> + <DialogHeader> + <DialogTitle>Upload Documents</DialogTitle> + <DialogDescription> + Upload documents for this stage submission + </DialogDescription> + </DialogHeader> + + {/* Document Info */} + <div className="grid gap-2 p-4 bg-muted rounded-lg"> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Document:</span> + <span className="text-sm">{submission.docNumber}</span> + {submission.vendorDocNumber && ( + <span className="text-sm text-muted-foreground"> + ({submission.vendorDocNumber}) + </span> + )} + </div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Stage:</span> + <Badge variant="secondary">{submission.stageName}</Badge> + </div> + <div className="flex items-center gap-2"> + <span className="text-sm font-medium">Current Revision:</span> + <span className="text-sm">Rev. {submission.latestRevisionNumber || 0}</span> + <Badge variant="outline" className="ml-2"> + Next: Rev. {(submission.latestRevisionNumber || 0) + 1} + </Badge> + </div> + </div> + + {/* File Upload Area */} + <div + className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors cursor-pointer" + onClick={() => fileInputRef.current?.click()} + > + <input + ref={fileInputRef} + type="file" + multiple + className="hidden" + onChange={handleFileChange} + accept="*/*" + /> + <Upload className="mx-auto h-10 w-10 text-gray-400 mb-3" /> + <p className="text-sm font-medium">Click to browse files</p> + <p className="text-xs text-gray-500 mt-1"> + You can select multiple files + </p> + </div> + + {/* File List */} + {files.length > 0 && ( + <> + <FileList> + {files.map((file, index) => ( + <FileListItem key={index}> + <FileListIcon> + <FileIcon className="h-4 w-4 text-muted-foreground" /> + </FileListIcon> + <FileListInfo> + <FileListName>{file.name}</FileListName> + </FileListInfo> + <FileListSize> + {file.size} + </FileListSize> + <FileListAction> + <Button + variant="ghost" + size="icon" + className="h-8 w-8" + onClick={(e) => { + e.stopPropagation() + removeFile(index) + }} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </Button> + </FileListAction> + </FileListItem> + ))} + </FileList> + + <div className="flex justify-between text-sm text-muted-foreground"> + <span>{files.length} file(s) selected</span> + <span>Total: {formatFileSize(totalSize)}</span> + </div> + </> + )} + + {/* Description */} + <div className="space-y-2"> + <Label htmlFor="description">Description (Optional)</Label> + <Textarea + id="description" + placeholder="Add a description for this submission..." + value={description} + onChange={(e) => setDescription(e.target.value)} + rows={3} + /> + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUploading} + > + Cancel + </Button> + <Button + onClick={handleUpload} + disabled={files.length === 0 || isUploading} + className="gap-2" + > + {isUploading ? ( + <> + <Loader2 className="h-4 w-4 animate-spin" /> + Uploading... + </> + ) : ( + <> + <Upload className="h-4 w-4" /> + Upload + </> + )} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx b/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx new file mode 100644 index 00000000..9a55a7fa --- /dev/null +++ b/lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx @@ -0,0 +1,520 @@ +// lib/vendor-document-list/plant/upload/components/view-submission-dialog.tsx +"use client" + +import * as React from "react" +import { useState, useEffect, useRef } from "react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Download, + Eye, + FileText, + Calendar, + User, + CheckCircle2, + XCircle, + Clock, + RefreshCw, + Loader2 +} from "lucide-react" +import { StageSubmissionView } from "@/db/schema" +import { formatDateTime, formatDate } from "@/lib/utils" +import { toast } from "sonner" +import { downloadFile, formatFileSize } from "@/lib/file-download" + +interface ViewSubmissionDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + submission: StageSubmissionView +} + +interface SubmissionDetail { + id: number + revisionNumber: number + submissionStatus: string + reviewStatus?: string + reviewComments?: string + submittedBy: string + submittedAt: Date + files: Array<{ + id: number + originalFileName: string + fileSize: number + uploadedAt: Date + syncStatus: string + storageUrl: string + }> +} + +// PDFTron 문서 뷰어 컴포넌트 +const DocumentViewer: React.FC<{ + open: boolean + onClose: () => void + files: Array<{ + id: number + originalFileName: string + storageUrl: string + }> +}> = ({ open, onClose, files }) => { + const [instance, setInstance] = useState<null | WebViewerInstance>(null) + const [viewerLoading, setViewerLoading] = useState<boolean>(true) + const [fileSetLoading, setFileSetLoading] = useState<boolean>(true) + const viewer = useRef<HTMLDivElement>(null) + const initialized = useRef(false) + const isCancelled = useRef(false) + + const cleanupHtmlStyle = () => { + const htmlElement = document.documentElement + const originalStyle = htmlElement.getAttribute("style") || "" + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")) + + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";") + } else { + htmlElement.removeAttribute("style") + } + } + + useEffect(() => { + if (open && !initialized.current) { + initialized.current = true + isCancelled.current = false + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("WebViewer 초기화 취소됨") + return + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: + "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css: "/globals.css", + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + setInstance(instance) + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) + instance.UI.disableElements([ + "addTabButton", + "multiTabsEmptyPage", + ]) + setViewerLoading(false) + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + setTimeout(() => cleanupHtmlStyle(), 500) + } + }, [open]) + + useEffect(() => { + const loadDocuments = async () => { + if (instance && files.length > 0) { + const { UI } = instance + const tabIds = [] + + for (const file of files) { + const fileExtension = file.originalFileName.split('.').pop()?.toLowerCase() + + const options = { + filename: file.originalFileName, + ...(fileExtension === 'xlsx' || fileExtension === 'xls' ? { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + } : {}), + } + + try { + const response = await fetch(file.storageUrl) + const blob = await response.blob() + const tab = await UI.TabManager.addTab(blob, options) + tabIds.push(tab) + } catch (error) { + console.error(`Failed to load ${file.originalFileName}:`, error) + toast.error(`Failed to load ${file.originalFileName}`) + } + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]) + } + + setFileSetLoading(false) + } + } + + loadDocuments() + }, [instance, files]) + + const handleClose = async () => { + if (!fileSetLoading) { + if (instance) { + try { + await instance.UI.dispose() + setInstance(null) + } catch (e) { + console.warn("dispose error", e) + } + } + + setTimeout(() => cleanupHtmlStyle(), 1000) + onClose() + } + } + + return ( + <Dialog open={open} onOpenChange={(val) => !val && handleClose()}> + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>Preview</DialogTitle> + {/* <DialogDescription>첨부파일 미리보기</DialogDescription> */} + </DialogHeader> + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viewerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + 문서 뷰어 로딩 중... + </p> + </div> + )} + </div> + </DialogContent> + </Dialog> + ) +} + +export function ViewSubmissionDialog({ + open, + onOpenChange, + submission +}: ViewSubmissionDialogProps) { + const [loading, setLoading] = useState(false) + const [submissionDetail, setSubmissionDetail] = useState<SubmissionDetail | null>(null) + const [downloadingFiles, setDownloadingFiles] = useState<Set<number>>(new Set()) + const [viewerOpen, setViewerOpen] = useState(false) + const [selectedFiles, setSelectedFiles] = useState<Array<{ + id: number + originalFileName: string + storageUrl: string + }>>([]) + + useEffect(() => { + if (open && submission.latestSubmissionId) { + fetchSubmissionDetail() + } + }, [open, submission.latestSubmissionId]) + + const fetchSubmissionDetail = async () => { + if (!submission.latestSubmissionId) return + + setLoading(true) + try { + const response = await fetch(`/api/stage-submissions/${submission.latestSubmissionId}`) + if (response.ok) { + const data = await response.json() + setSubmissionDetail(data) + } + } catch (error) { + console.error("Failed to fetch submission details:", error) + toast.error("Failed to load submission details") + } finally { + setLoading(false) + } + } + + const handleDownload = async (file: any) => { + setDownloadingFiles(prev => new Set(prev).add(file.id)) + + try { + const result = await downloadFile( + file.storageUrl, + file.originalFileName, + { + action: 'download', + showToast: true, + showSuccessToast: true, + onError: (error) => { + console.error("Download failed:", error) + toast.error(`Failed to download ${file.originalFileName}`) + }, + onSuccess: (fileName, fileSize) => { + console.log(`Successfully downloaded ${fileName}`) + } + } + ) + + if (!result.success) { + console.error("Download failed:", result.error) + } + } finally { + setDownloadingFiles(prev => { + const newSet = new Set(prev) + newSet.delete(file.id) + return newSet + }) + } + } + + // PDFTron으로 미리보기 처리 + const handlePreview = (file: any) => { + setSelectedFiles([{ + id: file.id, + originalFileName: file.originalFileName, + storageUrl: file.storageUrl + }]) + setViewerOpen(true) + } + + // 모든 파일 미리보기 + const handlePreviewAll = () => { + if (submissionDetail) { + const files = submissionDetail.files.map(file => ({ + id: file.id, + originalFileName: file.originalFileName, + storageUrl: file.storageUrl + })) + setSelectedFiles(files) + setViewerOpen(true) + } + } + + const getStatusBadge = (status?: string) => { + if (!status) return null + + const variant = status === "APPROVED" ? "success" : + status === "REJECTED" ? "destructive" : + status === "SUBMITTED" ? "default" : "secondary" + + return <Badge variant={variant}>{status}</Badge> + } + + return ( + <> + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="max-w-4xl max-h-[80vh]"> + <DialogHeader> + <DialogTitle>View Submission</DialogTitle> + <DialogDescription> + Submission details and attached files + </DialogDescription> + </DialogHeader> + + {loading ? ( + <div className="flex items-center justify-center py-8"> + <RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" /> + </div> + ) : submissionDetail ? ( + <Tabs defaultValue="details" className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="details">Details</TabsTrigger> + <TabsTrigger value="files"> + Files ({submissionDetail.files.length}) + </TabsTrigger> + </TabsList> + + <TabsContent value="details" className="space-y-4"> + <div className="grid gap-4"> + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Revision + </p> + <p className="text-lg font-medium"> + Rev. {submissionDetail.revisionNumber} + </p> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Status + </p> + <div className="flex items-center gap-2"> + {getStatusBadge(submissionDetail.submissionStatus)} + {submissionDetail.reviewStatus && + getStatusBadge(submissionDetail.reviewStatus)} + </div> + </div> + </div> + + <div className="grid grid-cols-2 gap-4"> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Submitted By + </p> + <div className="flex items-center gap-2"> + <User className="h-4 w-4 text-muted-foreground" /> + <span>{submissionDetail.submittedBy}</span> + </div> + </div> + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Submitted At + </p> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span>{formatDateTime(submissionDetail.submittedAt)}</span> + </div> + </div> + </div> + + {submissionDetail.reviewComments && ( + <div className="space-y-1"> + <p className="text-sm font-medium text-muted-foreground"> + Review Comments + </p> + <div className="p-3 bg-muted rounded-lg"> + <p className="text-sm">{submissionDetail.reviewComments}</p> + </div> + </div> + )} + </div> + </TabsContent> + + <TabsContent value="files"> + <div className="flex justify-end mb-4"> + <Button + variant="outline" + size="sm" + onClick={handlePreviewAll} + disabled={submissionDetail.files.length === 0} + > + <Eye className="h-4 w-4 mr-2" /> + 모든 파일 미리보기 + </Button> + </div> + <ScrollArea className="h-[400px]"> + <Table> + <TableHeader> + <TableRow> + <TableHead>File Name</TableHead> + <TableHead>Size</TableHead> + <TableHead>Upload Date</TableHead> + <TableHead>Sync Status</TableHead> + <TableHead className="text-right">Actions</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {submissionDetail.files.map((file) => { + const isDownloading = downloadingFiles.has(file.id) + + return ( + <TableRow key={file.id}> + <TableCell className="font-medium"> + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + {file.originalFileName} + </div> + </TableCell> + <TableCell>{formatFileSize(file.fileSize)}</TableCell> + <TableCell>{formatDate(file.uploadedAt)}</TableCell> + <TableCell> + <Badge + variant={ + file.syncStatus === "synced" ? "success" : + file.syncStatus === "failed" ? "destructive" : + "secondary" + } + className="text-xs" + > + {file.syncStatus} + </Badge> + </TableCell> + <TableCell className="text-right"> + <div className="flex justify-end gap-2"> + <Button + variant="ghost" + size="icon" + onClick={() => handleDownload(file)} + disabled={isDownloading} + title="Download" + > + {isDownloading ? ( + <RefreshCw className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4" /> + )} + </Button> + <Button + variant="ghost" + size="icon" + onClick={() => handlePreview(file)} + disabled={isDownloading} + title="Preview" + > + {isDownloading ? ( + <RefreshCw className="h-4 w-4 animate-spin" /> + ) : ( + <Eye className="h-4 w-4" /> + )} + </Button> + </div> + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </ScrollArea> + </TabsContent> + </Tabs> + ) : ( + <div className="text-center py-8 text-muted-foreground"> + No submission found + </div> + )} + </DialogContent> + </Dialog> + + {/* PDFTron 문서 뷰어 다이얼로그 */} + {viewerOpen && ( + <DocumentViewer + open={viewerOpen} + onClose={() => { + setViewerOpen(false) + setSelectedFiles([]) + }} + files={selectedFiles} + /> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/service.ts b/lib/vendor-document-list/plant/upload/service.ts new file mode 100644 index 00000000..18e6c132 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/service.ts @@ -0,0 +1,228 @@ +import db from "@/db/db" +import { stageSubmissionView, StageSubmissionView } from "@/db/schema" +import { and, asc, desc, eq, or, ilike, isTrue, sql, isNotNull, count } from "drizzle-orm" +import { filterColumns } from "@/lib/filter-columns" +import { GetStageSubmissionsSchema } from "./validation" +import { getServerSession } from 'next-auth/next' +import { authOptions } from '@/app/api/auth/[...nextauth]/route' +import { redirect } from "next/navigation" + +// Repository functions (동일) +async function selectStageSubmissions( + tx: typeof db, + params: { + where?: any + orderBy?: any + offset?: number + limit?: number + } +) { + const { where, orderBy = [desc(stageSubmissionView.isOverdue)], offset = 0, limit = 10 } = params + + const query = tx + .select() + .from(stageSubmissionView) + .$dynamic() + + if (where) query.where(where) + if (orderBy) query.orderBy(...(Array.isArray(orderBy) ? orderBy : [orderBy])) + query.limit(limit).offset(offset) + + return await query +} + +async function countStageSubmissions(tx: typeof db, where?: any) { + const query = tx + .select({ count: count() }) + .from(stageSubmissionView) + .$dynamic() + + if (where) query.where(where) + + const result = await query + return result[0]?.count ?? 0 +} + +// Service function with session check +export async function getStageSubmissions(input: GetStageSubmissionsSchema) { + // Session 체크 + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '로그인이 필요합니다.' + } + } + const vendorId = session.user.companyId // companyId가 vendorId + + try { + const offset = (input.page - 1) * input.perPage + + // Advanced filters + const advancedWhere = filterColumns({ + table: stageSubmissionView, + filters: input.filters, + joinOperator: input.joinOperator, + }) + + // Global search + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(stageSubmissionView.documentTitle, s), + ilike(stageSubmissionView.docNumber, s), + ilike(stageSubmissionView.vendorDocNumber, s), + ilike(stageSubmissionView.stageName, s) + // vendorName 검색 제거 (자기 회사만 보므로) + ) + } + + // Status filters + let statusWhere + if (input.submissionStatus && input.submissionStatus !== "all") { + switch (input.submissionStatus) { + case "required": + statusWhere = eq(stageSubmissionView.requiresSubmission, true) + break + case "submitted": + statusWhere = eq(stageSubmissionView.latestSubmissionStatus, "SUBMITTED") + break + case "approved": + statusWhere = eq(stageSubmissionView.latestReviewStatus, "APPROVED") + break + case "rejected": + statusWhere = eq(stageSubmissionView.latestReviewStatus, "REJECTED") + break + } + } + + // Sync status filter + let syncWhere + if (input.syncStatus && input.syncStatus !== "all") { + if (input.syncStatus === "pending") { + syncWhere = or( + eq(stageSubmissionView.latestSyncStatus, "pending"), + eq(stageSubmissionView.requiresSync, true) + ) + } else { + syncWhere = eq(stageSubmissionView.latestSyncStatus, input.syncStatus) + } + } + + // Project filter + let projectWhere = input.projectId ? eq(stageSubmissionView.projectId, input.projectId) : undefined + + // ✅ 벤더 필터 - session의 companyId 사용 + const vendorWhere = eq(stageSubmissionView.vendorId, vendorId) + + const finalWhere = and( + vendorWhere, // 항상 벤더 필터 적용 + advancedWhere, + globalWhere, + statusWhere, + syncWhere, + projectWhere + ) + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(stageSubmissionView[item.id]) + : asc(stageSubmissionView[item.id]) + ) + : [desc(stageSubmissionView.isOverdue), asc(stageSubmissionView.daysUntilDue)] + + // Transaction + const { data, total } = await db.transaction(async (tx) => { + const data = await selectStageSubmissions(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }) + const total = await countStageSubmissions(tx, finalWhere) + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount } + } catch (err) { + console.error("Error fetching stage submissions:", err) + return { data: [], pageCount: 0 } + } +} + +// 프로젝트 목록 조회 - 벤더 필터 적용 +export async function getProjects() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '로그인이 필요합니다.' + } + } + if (!session?.user?.companyId) { + return [] + } + + const vendorId = session.user.companyId + + const projects = await db + .selectDistinct({ + id: stageSubmissionView.projectId, + code: stageSubmissionView.projectCode, + }) + .from(stageSubmissionView) + .where( + and( + eq(stageSubmissionView.vendorId, vendorId), + isNotNull(stageSubmissionView.projectId) + ) + ) + .orderBy(asc(stageSubmissionView.projectCode)) + + return projects +} + +// 통계 조회 - 벤더별 +export async function getSubmissionStats() { + const session = await getServerSession(authOptions) + if (!session?.user?.id) { + return { + success: false, + error: '로그인이 필요합니다.' + } + } + + + if (!session?.user?.companyId) { + return { + pending: 0, + overdue: 0, + awaitingSync: 0, + completed: 0, + } + } + + const vendorId = session.user.companyId + + const stats = await db + .select({ + pending: sql<number>`count(*) filter (where ${stageSubmissionView.requiresSubmission} = true)::int`, + overdue: sql<number>`count(*) filter (where ${stageSubmissionView.isOverdue} = true)::int`, + awaitingSync: sql<number>`count(*) filter (where ${stageSubmissionView.requiresSync} = true)::int`, + completed: sql<number>`count(*) filter (where ${stageSubmissionView.latestReviewStatus} = 'APPROVED')::int`, + }) + .from(stageSubmissionView) + .where(eq(stageSubmissionView.vendorId, vendorId)) + + return stats[0] || { + pending: 0, + overdue: 0, + awaitingSync: 0, + completed: 0, + } +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/table.tsx b/lib/vendor-document-list/plant/upload/table.tsx new file mode 100644 index 00000000..92507900 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/table.tsx @@ -0,0 +1,223 @@ +// lib/vendor-document-list/plant/upload/table.tsx +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getColumns } from "./columns" +import { getStageSubmissions } from "./service" +import { StageSubmissionView } from "@/db/schema" +import { StageSubmissionToolbarActions } from "./toolbar-actions" +import { useRouter, useSearchParams, usePathname } from "next/navigation" +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" + +interface StageSubmissionsTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getStageSubmissions>>, + { projects: Array<{ id: number; code: string }> } + ]> + selectedProjectId?: number | null +} + +export function StageSubmissionsTable({ promises, selectedProjectId }: StageSubmissionsTableProps) { + const [{ data, pageCount }, { projects }] = React.use(promises) + const router = useRouter() + const pathname = usePathname() + const searchParams = useSearchParams() + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<StageSubmissionView> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 프로젝트 필터 핸들러 + const handleProjectChange = (projectId: number | null) => { + const current = new URLSearchParams(Array.from(searchParams.entries())) + + if (projectId) { + current.set("projectId", projectId.toString()) + } else { + current.delete("projectId") + } + + // 페이지를 1로 리셋 + current.set("page", "1") + + const search = current.toString() + const query = search ? `?${search}` : "" + + router.push(`${pathname}${query}`) + } + + // Filter fields - 프로젝트 필터 제거 + const filterFields: DataTableFilterField<StageSubmissionView>[] = [ + { + id: "stageStatus", + label: "Stage Status", + options: [ + { label: "Planned", value: "PLANNED" }, + { label: "In Progress", value: "IN_PROGRESS" }, + { label: "Submitted", value: "SUBMITTED" }, + { label: "Approved", value: "APPROVED" }, + { label: "Rejected", value: "REJECTED" }, + { label: "Completed", value: "COMPLETED" }, + ] + }, + { + id: "latestSubmissionStatus", + label: "Submission Status", + options: [ + { label: "Submitted", value: "SUBMITTED" }, + { label: "Under Review", value: "UNDER_REVIEW" }, + { label: "Draft", value: "DRAFT" }, + { label: "Withdrawn", value: "WITHDRAWN" }, + ] + }, + { + id: "requiresSubmission", + label: "Requires Submission", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + }, + { + id: "requiresSync", + label: "Requires Sync", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + }, + { + id: "isOverdue", + label: "Overdue", + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + } + ] + + const advancedFilterFields: DataTableAdvancedFilterField<StageSubmissionView>[] = [ + { + id: "docNumber", + label: "Doc Number", + type: "text", + }, + { + id: "documentTitle", + label: "Document Title", + type: "text", + }, + { + id: "stageName", + label: "Stage Name", + type: "text", + }, + { + id: "stagePlanDate", + label: "Due Date", + type: "date", + }, + { + id: "daysUntilDue", + label: "Days Until Due", + type: "number", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [ + { id: "isOverdue", desc: true }, + { id: "daysUntilDue", desc: false } + ], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => `${originalRow.documentId}-${originalRow.stageId}`, + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <> + <DataTable table={table}> + {/* 프로젝트 필터를 툴바 위에 배치 */} + <div className="flex items-center justify-between pb-3"> + <ProjectFilter + projects={projects} + value={selectedProjectId} + onValueChange={handleProjectChange} + /> + <div className="text-sm text-muted-foreground"> + {data.length} record(s) found + </div> + </div> + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <StageSubmissionToolbarActions + table={table} + rowAction={rowAction} + setRowAction={setRowAction} + /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* Upload Dialog */} + {rowAction?.type === "upload" && ( + <SingleUploadDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + submission={rowAction.row.original} + onUploadComplete={() => { + setRowAction(null) + // 테이블 새로고침 + window.location.reload() + }} + /> + )} + + {/* View Submission Dialog */} + {rowAction?.type === "view" && ( + <ViewSubmissionDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + submission={rowAction.row.original} + /> + )} + + {/* History Dialog */} + {rowAction?.type === "history" && ( + <HistoryDialog + open={true} + onOpenChange={(open) => !open && setRowAction(null)} + submission={rowAction.row.original} + /> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/toolbar-actions.tsx b/lib/vendor-document-list/plant/upload/toolbar-actions.tsx new file mode 100644 index 00000000..072fd72d --- /dev/null +++ b/lib/vendor-document-list/plant/upload/toolbar-actions.tsx @@ -0,0 +1,242 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCw, Upload, Send, AlertCircle } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import { StageSubmissionView } from "@/db/schema" +import { DataTableRowAction } from "@/types/table" +import { MultiUploadDialog } from "./components/multi-upload-dialog" +import { useRouter, useSearchParams } from "next/navigation" + +interface StageSubmissionToolbarActionsProps { + table: Table<StageSubmissionView> + rowAction: DataTableRowAction<StageSubmissionView> | null + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<StageSubmissionView> | null>> +} + +export function StageSubmissionToolbarActions({ + table, + rowAction, + setRowAction +}: StageSubmissionToolbarActionsProps) { + const selectedRows = table.getFilteredSelectedRowModel().rows + const router = useRouter() + const searchParams = useSearchParams() + + const projectId = searchParams.get('projectId') + + + const [isSyncing, setIsSyncing] = React.useState(false) + const [showSyncDialog, setShowSyncDialog] = React.useState(false) + const [syncTargets, setSyncTargets] = React.useState<typeof selectedRows>([]) + + const handleUploadComplete = () => { + // Refresh table + router.refresh() + } + + const handleSyncClick = () => { + const rowsRequiringSync = selectedRows.filter( + row => row.original.requiresSync && row.original.latestSubmissionId + ) + setSyncTargets(rowsRequiringSync) + setShowSyncDialog(true) + } + + const handleSyncConfirm = async () => { + setShowSyncDialog(false) + setIsSyncing(true) + + try { + // Extract submission IDs + const submissionIds = syncTargets + .map(row => row.original.latestSubmissionId) + .filter((id): id is number => id !== null) + + if (submissionIds.length === 0) { + toast.error("No submissions to sync.") + return + } + + // API call + const response = await fetch('/api/stage-submissions/sync', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ submissionIds }), + }) + + const result = await response.json() + + if (result.success) { + toast.success(result.message) + + // Display detailed information for successful items + if (result.results?.details) { + const successCount = result.results.details.filter((d: any) => d.success).length + const failedCount = result.results.details.filter((d: any) => !d.success).length + + if (failedCount > 0) { + toast.warning(`${successCount} succeeded, ${failedCount} failed`) + } + } + + // Refresh table + router.refresh() + table.toggleAllPageRowsSelected(false) // Deselect all + } else { + toast.error(result.error || "Sync failed") + } + } catch (error) { + console.error("Sync error:", error) + toast.error("An error occurred during synchronization.") + } finally { + setIsSyncing(false) + } + } + + return ( + <> + <div className="flex items-center gap-2"> + {projectId && ( + <MultiUploadDialog + projectId={parseInt(projectId)} + // projectCode={projectCode} + onUploadComplete={handleUploadComplete} + /> + )} + {selectedRows.length > 0 && ( + <> + {/* Bulk Upload for selected rows that require submission */} + {selectedRows.some(row => row.original.requiresSubmission) && ( + <Button + variant="outline" + size="sm" + onClick={() => { + // Filter selected rows that require submission + const rowsRequiringSubmission = selectedRows.filter( + row => row.original.requiresSubmission + ) + // Open bulk upload dialog + console.log("Bulk upload for:", rowsRequiringSubmission) + }} + className="gap-2" + > + <Upload className="size-4" /> + <span>Upload ({selectedRows.filter(r => r.original.requiresSubmission).length})</span> + </Button> + )} + + {/* Bulk Sync for selected rows that need syncing */} + {selectedRows.some(row => row.original.requiresSync && row.original.latestSubmissionId) && ( + <Button + variant="outline" + size="sm" + onClick={handleSyncClick} + disabled={isSyncing} + className="gap-2" + > + {isSyncing ? ( + <> + <RefreshCw className="size-4 animate-spin" /> + <span>Syncing...</span> + </> + ) : ( + <> + <RefreshCw className="size-4" /> + <span>Sync ({selectedRows.filter(r => r.original.requiresSync && r.original.latestSubmissionId).length})</span> + </> + )} + </Button> + )} + </> + )} + + {/* Export Button */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: `stage-submissions-${new Date().toISOString().split('T')[0]}`, + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + + {/* Sync Confirmation Dialog */} + <AlertDialog open={showSyncDialog} onOpenChange={setShowSyncDialog}> + <AlertDialogContent> + <AlertDialogHeader> + <AlertDialogTitle className="flex items-center gap-2"> + <RefreshCw className="size-5" /> + Sync to Buyer System + </AlertDialogTitle> + <AlertDialogDescription className="space-y-3"> + <div> + Are you sure you want to sync {syncTargets.length} selected submission(s) to the buyer system? + </div> + <div className="space-y-2 rounded-lg bg-muted p-3"> + <div className="text-sm font-medium">Items to sync:</div> + <ul className="text-sm space-y-1"> + {syncTargets.slice(0, 3).map((row, idx) => ( + <li key={idx} className="flex items-center gap-2"> + <span className="text-muted-foreground">•</span> + <span>{row.original.docNumber}</span> + <span className="text-muted-foreground">-</span> + <span>{row.original.stageName}</span> + <span className="text-muted-foreground"> + (Rev.{row.original.latestRevisionNumber}) + </span> + </li> + ))} + {syncTargets.length > 3 && ( + <li className="text-muted-foreground"> + ... and {syncTargets.length - 3} more + </li> + )} + </ul> + </div> + <div className="flex items-start gap-2 text-sm text-amber-600"> + <AlertCircle className="size-4 mt-0.5 shrink-0" /> + <div> + Synchronized files will be sent to the SHI Buyer System and + cannot be recalled after transmission. + </div> + </div> + </AlertDialogDescription> + </AlertDialogHeader> + <AlertDialogFooter> + <AlertDialogCancel>Cancel</AlertDialogCancel> + <AlertDialogAction + onClick={handleSyncConfirm} + // className="bg-samsung hover:bg-samsung/90" + > + Start Sync + </AlertDialogAction> + </AlertDialogFooter> + </AlertDialogContent> + </AlertDialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/util/filie-parser.ts b/lib/vendor-document-list/plant/upload/util/filie-parser.ts new file mode 100644 index 00000000..42dac9b4 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/util/filie-parser.ts @@ -0,0 +1,132 @@ +// lib/vendor-document-list/plant/upload/utils/file-parser.ts + +export interface ParsedFileName { + docNumber: string + stageName: string + revision: string + extension: string + originalName: string + isValid: boolean + error?: string +} + +export function parseFileName(fileName: string): ParsedFileName { + try { + // 확장자 분리 + const lastDotIndex = fileName.lastIndexOf('.') + if (lastDotIndex === -1) { + return { + docNumber: '', + stageName: '', + revision: '', + extension: '', + originalName: fileName, + isValid: false, + error: 'No file extension found' + } + } + + const extension = fileName.substring(lastDotIndex + 1) + const nameWithoutExt = fileName.substring(0, lastDotIndex) + + // 언더스코어로 분리 (최소 3개 부분 필요) + const parts = nameWithoutExt.split('_') + + if (parts.length < 3) { + return { + docNumber: '', + stageName: '', + revision: '', + extension, + originalName: fileName, + isValid: false, + error: `Invalid format. Expected: DocNumber_StageName_Revision.${extension}` + } + } + + // 파싱 결과 + const docNumber = parts[0] + const stageName = parts.slice(1, -1).join('_') // 중간 부분이 여러 개일 수 있음 + const revision = parts[parts.length - 1] // 마지막 부분이 리비전 + + // 기본 검증 + if (!docNumber || !stageName || !revision) { + return { + docNumber: '', + stageName: '', + revision: '', + extension, + originalName: fileName, + isValid: false, + error: 'Missing required parts' + } + } + + return { + docNumber, + stageName, + revision, + extension, + originalName: fileName, + isValid: true + } + } catch (error) { + return { + docNumber: '', + stageName: '', + revision: '', + extension: '', + originalName: fileName, + isValid: false, + error: 'Failed to parse filename' + } + } +} + +// 리비전 번호 추출 (숫자 우선, 없으면 문자를 숫자로 변환) +export function extractRevisionNumber(revision: string): number { + const cleanRevision = revision.toLowerCase().replace(/[^a-z0-9]/g, '') + + // Rev0, Rev1 형식 + const revMatch = cleanRevision.match(/rev(\d+)/) + if (revMatch) return parseInt(revMatch[1]) + + // R0, R1 형식 + const rMatch = cleanRevision.match(/r(\d+)/) + if (rMatch) return parseInt(rMatch[1]) + + // v1, v2 형식 + const vMatch = cleanRevision.match(/v(\d+)/) + if (vMatch) return parseInt(vMatch[1]) + + // 단순 숫자 + const numMatch = cleanRevision.match(/^(\d+)$/) + if (numMatch) return parseInt(numMatch[1]) + + // RevA, RevB 또는 A, B 형식 -> 숫자로 변환 (A=1, B=2, etc.) + const alphaMatch = cleanRevision.match(/^(?:rev)?([a-z])$/i) + if (alphaMatch) { + return alphaMatch[1].toUpperCase().charCodeAt(0) - 64 // A=1, B=2, C=3... + } + + // 기본값 + return 0 +} + +// 리비전 코드 정규화 (DB 저장용) +export function normalizeRevisionCode(revision: string): string { + // Rev0 -> 0, RevA -> A, v1 -> 1 등으로 정규화 + const cleanRevision = revision.toLowerCase() + + // Rev 제거 + if (cleanRevision.startsWith('rev')) { + return revision.substring(3) + } + + // R, v 제거 + if (cleanRevision.startsWith('r') || cleanRevision.startsWith('v')) { + return revision.substring(1) + } + + return revision +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/upload/validation.ts b/lib/vendor-document-list/plant/upload/validation.ts new file mode 100644 index 00000000..80a7d390 --- /dev/null +++ b/lib/vendor-document-list/plant/upload/validation.ts @@ -0,0 +1,35 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + } from "nuqs/server" + import * as z from "zod" + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + import { StageSubmissionView } from "@/db/schema" + + export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(20), + sort: getSortingStateParser<StageSubmissionView>().withDefault([ + { id: "isOverdue", desc: true }, + { id: "daysUntilDue", desc: false }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + + // 프로젝트 필터만 유지 + projectId: parseAsInteger, + syncStatus: parseAsStringEnum(["all", "pending", "syncing", "synced", "failed", "partial"]).withDefault("all"), + submissionStatus: parseAsStringEnum(["all", "required", "submitted", "approved", "rejected"]).withDefault("all"), + }) + + export type GetStageSubmissionsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
\ No newline at end of file diff --git a/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx index 66ddee47..8054b128 100644 --- a/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx +++ b/lib/vendors/rfq-history-table/rfq-history-table-columns.tsx @@ -3,7 +3,7 @@ import * as React from "react"
import { type DataTableRowAction } from "@/types/table"
import { type ColumnDef } from "@tanstack/react-table"
-import { Ellipsis } from "lucide-react"
+import { Ellipsis, MoreHorizontal } from "lucide-react"
import { toast } from "sonner"
import { getErrorMessage } from "@/lib/handle-error"
@@ -15,13 +15,6 @@ import { DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
- DropdownMenuRadioGroup,
- DropdownMenuRadioItem,
- DropdownMenuSeparator,
- DropdownMenuShortcut,
- DropdownMenuSub,
- DropdownMenuSubContent,
- DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"
@@ -30,7 +23,6 @@ import { VendorItem, vendors } from "@/db/schema/vendors" import { modifyVendor } from "../service"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { getRFQStatusIcon } from "@/lib/tasks/utils"
import { rfqHistoryColumnsConfig } from "@/config/rfqHistoryColumnsConfig"
export interface RfqHistoryRow {
@@ -61,13 +53,13 @@ export interface RfqHistoryRow { interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqHistoryRow> | null>>;
- openItemsModal: (rfqId: number) => void;
+ onViewDetails: (rfqId: number) => void;
}
/**
* tanstack table 컬럼 정의 (중첩 헤더 버전)
*/
-export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): ColumnDef<RfqHistoryRow>[] {
+export function getColumns({ setRowAction, onViewDetails }: GetColumnsProps): ColumnDef<RfqHistoryRow>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
@@ -112,14 +104,14 @@ export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): C variant="ghost"
className="flex size-8 p-0 data-[state=open]:bg-muted"
>
- <Ellipsis className="size-4" aria-hidden="true" />
+ <span className="text-lg">⋯</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "update" })}
+ onSelect={() => onViewDetails(row.original.id)}
>
- View Details
+ 견적상세
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -129,30 +121,60 @@ export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): C }
// ----------------------------------------------------------------
- // 3) 일반 컬럼들
+ // 3) 개별 컬럼들 (그룹 없음)
// ----------------------------------------------------------------
- const basicColumns: ColumnDef<RfqHistoryRow>[] = rfqHistoryColumnsConfig.map((cfg) => {
+ const basicColumns1: ColumnDef<RfqHistoryRow>[] = []
+ const quotationGroupColumns: ColumnDef<RfqHistoryRow>[] = []
+ const basicColumns2: ColumnDef<RfqHistoryRow>[] = []
+
+ const sortableIds = new Set([
+ "rfqType",
+ "status",
+ "rfqCode",
+ "projectInfo",
+ "packageInfo",
+ "materialInfo",
+ "currency",
+ "totalAmount",
+ "leadTime",
+ "paymentTerms",
+ "incoterms",
+ "shippingLocation",
+ "contractInfo",
+ "rfqSendDate",
+ "submittedAt",
+ "picName",
+ ])
+
+ rfqHistoryColumnsConfig.forEach((cfg) => {
+ const isSortable = sortableIds.has(cfg.id)
const column: ColumnDef<RfqHistoryRow> = {
accessorKey: cfg.id,
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title={cfg.label} />
),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
size: cfg.size,
+ enableSorting: isSortable,
}
- if (cfg.id === "description") {
+ if (cfg.id === "materialInfo") {
column.cell = ({ row }) => {
- const description = row.original.description
- if (!description) return null
+ const materialInfo = row.original.materialInfo
+ if (!materialInfo) return null
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="break-words whitespace-normal line-clamp-2">
- {description}
+ {materialInfo}
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[400px] whitespace-pre-wrap break-words">
- {description}
+ {materialInfo}
</TooltipContent>
</Tooltip>
)
@@ -163,10 +185,8 @@ export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): C column.cell = ({ row }) => {
const statusVal = row.original.status
if (!statusVal) return null
- const Icon = getRFQStatusIcon(statusVal)
return (
- <div className="flex items-center">
- <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" />
+ <div className="whitespace-nowrap">
<span className="capitalize">{statusVal}</span>
</div>
)
@@ -176,48 +196,57 @@ export function getColumns({ setRowAction, openItemsModal }: GetColumnsProps): C if (cfg.id === "totalAmount") {
column.cell = ({ row }) => {
const amount = row.original.totalAmount
- const currency = row.original.currency
- if (!amount || !currency) return null
+ if (!amount) return null
return (
<div className="whitespace-nowrap">
- {`${currency} ${amount.toLocaleString()}`}
+ {amount.toLocaleString()}
</div>
)
}
}
- if (cfg.id === "dueDate" || cfg.id === "createdAt") {
- column.cell = ({ row }) => (
- <div className="whitespace-nowrap">
- {formatDate(row.getValue(cfg.id), "KR")}
- </div>
- )
+ if (cfg.id === "rfqSendDate" || cfg.id === "submittedAt") {
+ column.cell = ({ row }) => {
+ const v = row.getValue(cfg.id) as Date | null
+ if (!v) return <div className="whitespace-nowrap">-</div>
+ return (
+ <div className="whitespace-nowrap">{formatDate(v, "KR")}</div>
+ )
+ }
+ }
+
+ // 컬럼을 적절한 배열에 분류
+ if (cfg.group === "견적정보") {
+ quotationGroupColumns.push(column)
+ } else if (["contractInfo", "rfqSendDate", "submittedAt", "picName"].includes(cfg.id)) {
+ basicColumns2.push(column)
+ } else {
+ basicColumns1.push(column)
}
return column
})
- const itemsColumn: ColumnDef<RfqHistoryRow> = {
- id: "items",
- header: "Items",
- cell: ({ row }) => {
- const rfq = row.original;
- const count = rfq.itemCount || 0;
- return (
- <Button variant="ghost" onClick={() => openItemsModal(rfq.id)}>
- {count === 0 ? "No Items" : `${count} Items`}
- </Button>
- )
- },
- }
-
// ----------------------------------------------------------------
- // 4) 최종 컬럼 배열
+ // 4) 최종 컬럼 배열 (bid-history-table-columns.tsx 방식)
// ----------------------------------------------------------------
+ const createGroupColumn = (groupName: string, columns: ColumnDef<RfqHistoryRow>[]): ColumnDef<RfqHistoryRow> => {
+ return {
+ id: `group-${groupName.replace(/\s+/g, '-')}`,
+ header: groupName,
+ columns: columns,
+ meta: {
+ isGroupColumn: true,
+ groupBorders: true,
+ } as any
+ }
+ }
+
return [
selectColumn,
- ...basicColumns,
- itemsColumn,
+ ...basicColumns1,
+ ...(quotationGroupColumns.length > 0 ? [createGroupColumn("견적정보", quotationGroupColumns)] : []),
+ ...basicColumns2,
actionsColumn,
]
}
\ No newline at end of file diff --git a/lib/vendors/rfq-history-table/rfq-history-table.tsx b/lib/vendors/rfq-history-table/rfq-history-table.tsx index 71830303..11a4bf9d 100644 --- a/lib/vendors/rfq-history-table/rfq-history-table.tsx +++ b/lib/vendors/rfq-history-table/rfq-history-table.tsx @@ -14,26 +14,38 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { getColumns } from "./rfq-history-table-columns" import { getRfqHistory } from "../service" import { RfqHistoryTableToolbarActions } from "./rfq-history-table-toolbar-actions" -import { RfqItemsTableDialog } from "./rfq-items-table-dialog" import { getRFQStatusIcon } from "@/lib/tasks/utils" import { TooltipProvider } from "@/components/ui/tooltip" +import { useRouter } from "next/navigation" export interface RfqHistoryRow { id: number; + rfqType: string | null; + status: string; rfqCode: string | null; + projectInfo: string | null; + packageInfo: string | null; + materialInfo: string | null; + // 견적정보 세부 필드들 + currency: string | null; + totalAmount: number | null; + leadTime: string | null; + paymentTerms: string | null; + incoterms: string | null; + shippingLocation: string | null; + contractInfo: string | null; + rfqSendDate: Date | null; + submittedAt: Date | null; + picName: string | null; + // 기존 필드들 (호환성을 위해 유지) projectCode: string | null; projectName: string | null; description: string | null; dueDate: Date; - status: "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED"; vendorStatus: string; - totalAmount: number | null; - currency: string | null; - leadTime: string | null; itemCount: number; tbeResult: string | null; cbeResult: string | null; - createdAt: Date; items: { rfqId: number; id: number; @@ -42,6 +54,7 @@ export interface RfqHistoryRow { quantity: number | null; uom: string | null; }[]; + actions?: any; // actions 컬럼용 } interface RfqHistoryTableProps { @@ -50,68 +63,117 @@ interface RfqHistoryTableProps { Awaited<ReturnType<typeof getRfqHistory>>, ] > + lng: string + vendorId: number } -export function VendorRfqHistoryTable({ promises }: RfqHistoryTableProps) { - const [{ data, pageCount }] = React.use(promises) +export function VendorRfqHistoryTable({ promises, lng, vendorId }: RfqHistoryTableProps) { + const [{ data = [], pageCount = 0 }] = React.use(promises) + const router = useRouter() - const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqHistoryRow> | null>(null) + const [, setRowAction] = React.useState<DataTableRowAction<RfqHistoryRow> | null>(null) - const [itemsModalOpen, setItemsModalOpen] = React.useState(false); - const [selectedRfq, setSelectedRfq] = React.useState<RfqHistoryRow | null>(null); - - const openItemsModal = React.useCallback((rfqId: number) => { - const rfq = data.find(r => r.id === rfqId); - if (rfq) { - setSelectedRfq(rfq); - setItemsModalOpen(true); - } - }, [data]); + const onViewDetails = React.useCallback((rfqId: number) => { + router.push(`/${lng}/evcp/rfq-last/${rfqId}`); + }, [router, lng]); const columns = React.useMemo(() => getColumns({ setRowAction, - openItemsModal, - }), [setRowAction, openItemsModal]); + onViewDetails, + }), [setRowAction, onViewDetails]); const filterFields: DataTableFilterField<RfqHistoryRow>[] = [ { - id: "rfqCode", - label: "RFQ Code", - placeholder: "Filter RFQ Code...", + id: "rfqType", + label: "견적종류", + options: [ + { label: "ITB", value: "ITB" }, + { label: "RFQ", value: "RFQ" }, + { label: "일반", value: "일반" }, + { label: "정기견적", value: "정기견적" } + ], }, { id: "status", - label: "Status", - options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({ - label: toSentenceCase(status), - value: status, - icon: getRFQStatusIcon(status), - })), + label: "견적상태", + options: [ + { label: "ITB 발송", value: "ITB 발송" }, + { label: "Short List 확정", value: "Short List 확정" }, + { label: "최종업체선정", value: "최종업체선정" }, + { label: "견적접수", value: "견적접수" }, + { label: "견적평가중", value: "견적평가중" }, + { label: "견적완료", value: "견적완료" } + ], }, + { id: "rfqCode", label: "견적번호", placeholder: "견적번호로 검색..." }, + { id: "projectInfo", label: "프로젝트", placeholder: "프로젝트로 검색..." }, + { id: "packageInfo", label: "PKG No.", placeholder: "PKG로 검색..." }, + { id: "materialInfo", label: "자재그룹", placeholder: "자재그룹으로 검색..." }, { - id: "vendorStatus", - label: "Vendor Status", - placeholder: "Filter Vendor Status...", - } + id: "currency", + label: "통화", + options: [ + { label: "USD", value: "USD" }, + { label: "KRW", value: "KRW" }, + { label: "EUR", value: "EUR" }, + { label: "JPY", value: "JPY" } + ], + }, + { id: "paymentTerms", label: "지급조건", placeholder: "지급조건으로 검색..." }, + { id: "incoterms", label: "Incoterms", placeholder: "Incoterms로 검색..." }, + { id: "shippingLocation", label: "선적지", placeholder: "선적지로 검색..." }, + { id: "picName", label: "견적담당자", placeholder: "담당자로 검색..." }, ] const advancedFilterFields: DataTableAdvancedFilterField<RfqHistoryRow>[] = [ - { id: "rfqCode", label: "RFQ Code", type: "text" }, - { id: "projectCode", label: "Project Code", type: "text" }, - { id: "projectName", label: "Project Name", type: "text" }, - { - id: "status", - label: "RFQ Status", + { + id: "rfqType", + label: "견적종류", + type: "multi-select", + options: [ + { label: "ITB", value: "ITB" }, + { label: "RFQ", value: "RFQ" }, + { label: "일반", value: "일반" }, + { label: "정기견적", value: "정기견적" } + ], + }, + { + id: "status", + label: "견적상태", + type: "multi-select", + options: [ + { label: "ITB 발송", value: "ITB 발송" }, + { label: "Short List 확정", value: "Short List 확정" }, + { label: "최종업체선정", value: "최종업체선정" }, + { label: "견적접수", value: "견적접수" }, + { label: "견적평가중", value: "견적평가중" }, + { label: "견적완료", value: "견적완료" } + ], + }, + { id: "rfqCode", label: "견적번호", type: "text" }, + { id: "projectInfo", label: "프로젝트", type: "text" }, + { id: "packageInfo", label: "PKG No.", type: "text" }, + { id: "materialInfo", label: "자재그룹", type: "text" }, + { + id: "currency", + label: "통화", type: "multi-select", - options: ["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"].map((status) => ({ - label: toSentenceCase(status), - value: status, - icon: getRFQStatusIcon(status), - })), + options: [ + { label: "USD", value: "USD" }, + { label: "KRW", value: "KRW" }, + { label: "EUR", value: "EUR" }, + { label: "JPY", value: "JPY" } + ], }, - { id: "vendorStatus", label: "Vendor Status", type: "text" }, - { id: "dueDate", label: "Due Date", type: "date" }, - { id: "createdAt", label: "Created At", type: "date" }, + { id: "totalAmount", label: "총 견적금액", type: "number" }, + { id: "leadTime", label: "업체 L/T(W)", type: "text" }, + { id: "paymentTerms", label: "지급조건", type: "text" }, + { id: "incoterms", label: "Incoterms", type: "text" }, + { id: "shippingLocation", label: "선적지", type: "text" }, + { id: "contractInfo", label: "PO/계약정보", type: "text" }, + { id: "rfqSendDate", label: "견적요청일", type: "date" }, + { id: "submittedAt", label: "견적회신일", type: "date" }, + { id: "picName", label: "견적담당자", type: "text" }, ] const { table } = useDataTable({ @@ -122,13 +184,13 @@ export function VendorRfqHistoryTable({ promises }: RfqHistoryTableProps) { enablePinning: true, enableAdvancedFilter: true, initialState: { - sorting: [{ id: "createdAt", desc: true }], + sorting: [{ id: "rfqSendDate", desc: true }], columnPinning: { right: ["actions"] }, }, - getRowId: (originalRow) => String(originalRow.id), - shallow: true, + getRowId: (originalRow, index) => originalRow?.id ? String(originalRow.id) : String(index), + shallow: false, clearOnDefault: true, - }) + }); return ( <> @@ -141,15 +203,9 @@ export function VendorRfqHistoryTable({ promises }: RfqHistoryTableProps) { filterFields={advancedFilterFields} shallow={false} > - <RfqHistoryTableToolbarActions table={table} /> </DataTableAdvancedToolbar> </DataTable> - <RfqItemsTableDialog - open={itemsModalOpen} - onOpenChange={setItemsModalOpen} - items={selectedRfq?.items ?? []} - /> </TooltipProvider> </> ) diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 9362a88c..596a52a0 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -9,7 +9,8 @@ import crypto from 'crypto'; import { v4 as uuidv4 } from 'uuid'; import { saveDRMFile } from "@/lib/file-stroage"; import { decryptWithServerAction } from "@/components/drm/drmUtils"; - +import { rfqLastVendorResponses, vendorQuotationView } from "@/db/schema/rfqVendor"; +import { rfqsLast, rfqLastDetails } from "@/db/schema/rfqLast"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; @@ -1213,117 +1214,235 @@ const removeVendormaterialsSchema = z.object({ }) - export async function getRfqHistory(input: GetRfqHistorySchema, vendorId: number) { - return unstable_cache( - async () => { - try { - logger.info({ vendorId, input }, "Starting getRfqHistory"); + try { + logger.info({ vendorId, input }, "Starting getRfqHistory"); - const offset = (input.page - 1) * input.perPage; + const offset = (input.page - 1) * input.perPage; - // 기본 where 조건 (vendorId) - const vendorWhere = eq(vendorRfqView.vendorId, vendorId); - logger.debug({ vendorWhere }, "Vendor where condition"); + // 기본 where 조건 (vendorId) + const vendorWhere = eq(rfqLastVendorResponses.vendorId, vendorId); + logger.debug({ vendorWhere }, "Vendor where condition"); - // 고급 필터링 - const advancedWhere = filterColumns({ - table: vendorRfqView, - filters: input.filters, - joinOperator: input.joinOperator, - }); - logger.debug({ advancedWhere }, "Advanced where condition"); + // 고급 필터링 + const advancedWhere = filterColumns({ + table: rfqsLast, + filters: input.filters, + joinOperator: input.joinOperator, + customColumnMapping: { + projectCode: { table: projects, column: "code" }, + projectName: { table: projects, column: "name" }, + projectInfo: { table: projects, column: "code" }, + packageInfo: { table: rfqsLast, column: "packageNo" }, + currency: { table: rfqLastVendorResponses, column: "vendorCurrency" }, + totalAmount: { table: rfqLastVendorResponses, column: "totalAmount" }, + paymentTerms: { table: rfqLastVendorResponses, column: "vendorPaymentTermsCode" }, + incoterms: { table: rfqLastVendorResponses, column: "vendorIncotermsCode" }, + shippingLocation: { table: rfqLastVendorResponses, column: "vendorPlaceOfShipping" }, + leadTime: { table: rfqLastVendorResponses, column: "vendorDeliveryDate" }, + contractInfo: { table: rfqLastDetails, column: "contractNo" }, + rfqSendDate: { table: rfqsLast, column: "rfqSendDate" }, + submittedAt: { table: rfqLastVendorResponses, column: "submittedAt" }, + }, + }); + logger.debug({ advancedWhere }, "Advanced where condition"); - // 글로벌 검색 - let globalWhere; - if (input.search) { - const s = `%${input.search}%`; - globalWhere = or( - ilike(vendorRfqView.rfqCode, s), - ilike(vendorRfqView.projectCode, s), - ilike(vendorRfqView.projectName, s) - ); - logger.debug({ globalWhere, search: input.search }, "Global search condition"); - } + // 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(rfqsLast.rfqCode, s), + ilike(projects.code, s), + ilike(projects.name, s), + ilike(rfqsLast.rfqType, s), + ilike(rfqsLast.status, s) + ); + logger.debug({ globalWhere, search: input.search }, "Global search condition"); + } - const finalWhere = and( - advancedWhere, - globalWhere, - vendorWhere - ); - logger.debug({ finalWhere }, "Final where condition"); + const finalWhere = and( + advancedWhere, + globalWhere, + vendorWhere + ); + logger.debug({ finalWhere }, "Final where condition"); - // 정렬 조건 - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(rfqs[item.id]) : asc(rfqs[item.id]) - ) - : [desc(rfqs.createdAt)]; - logger.debug({ orderBy }, "Order by condition"); + // 정렬 조건 - 동적 매핑 + const sortFieldMap: Record<string, any> = { + rfqType: rfqsLast.rfqType, + status: rfqsLast.status, + rfqCode: rfqsLast.rfqCode, + projectInfo: projects.code, + projectCode: projects.code, + projectName: projects.name, + packageNo: rfqsLast.packageNo, + packageName: rfqsLast.packageName, + packageInfo: rfqsLast.packageNo, + materialInfo: projects.code, + majorItemMaterialCategory: sql<string | null>`COALESCE( + (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), + (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) + )`, + majorItemMaterialDescription: sql<string | null>`COALESCE( + (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), + (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) + )`, + currency: rfqLastVendorResponses.vendorCurrency, + totalAmount: rfqLastVendorResponses.totalAmount, + leadTime: rfqLastVendorResponses.vendorDeliveryDate, + paymentTerms: rfqLastVendorResponses.vendorPaymentTermsCode, + incoterms: rfqLastVendorResponses.vendorIncotermsCode, + shippingLocation: rfqLastVendorResponses.vendorPlaceOfShipping, + contractInfo: rfqLastDetails.contractNo, + rfqSendDate: rfqsLast.rfqSendDate, + submittedAt: rfqLastVendorResponses.submittedAt, + picName: rfqsLast.picName, + }; - // 트랜잭션으로 데이터 조회 - const { data, total } = await db.transaction(async (tx) => { - logger.debug("Starting transaction for RFQ history query"); + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => { + const field = sortFieldMap[item.id]; + if (!field) { + logger.warn({ sortField: item.id }, "Unknown sort field, using default"); + return desc(rfqsLast.rfqSendDate); + } + return item.desc ? desc(field) : asc(field); + }) + : [desc(rfqsLast.rfqSendDate)]; + logger.debug({ orderBy }, "Order by condition"); - const data = await selectRfqHistory(tx, { - where: finalWhere, - orderBy, - offset, - limit: input.perPage, - }); - logger.debug({ dataLength: data.length }, "RFQ history data fetched"); + // 트랜잭션으로 데이터 조회 + const { data, total } = await db.transaction(async (tx) => { + logger.debug("Starting transaction for RFQ history query"); - // RFQ 아이템 정보 조회 - const rfqIds = data.map(rfq => rfq.id); - const items = await tx - .select({ - rfqId: rfqItems.rfqId, - id: rfqItems.id, - itemCode: rfqItems.itemCode, - description: rfqItems.description, - quantity: rfqItems.quantity, - uom: rfqItems.uom, - }) - .from(rfqItems) - .where(inArray(rfqItems.rfqId, rfqIds)); + // RFQ History 데이터 조회 - rfqsLast 기준으로 조인 (bid-history와 동일한 패턴) + const rfqHistoryData = await tx + .select({ + id: rfqsLast.id, + rfqType: rfqsLast.rfqType, + status: rfqsLast.status, + rfqCode: rfqsLast.rfqCode, + projectCode: projects.code, + projectName: projects.name, + packageNo: rfqsLast.packageNo, + packageName: rfqsLast.packageName, + majorItemMaterialCategory: sql<string | null>`COALESCE( + (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), + (SELECT material_category FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) + )`, + majorItemMaterialDescription: sql<string | null>`COALESCE( + (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} AND major_yn = true LIMIT 1), + (SELECT material_description FROM rfq_pr_items WHERE rfqs_last_id = ${rfqsLast.id} LIMIT 1) + )`, + vendorCurrency: rfqLastVendorResponses.vendorCurrency, + totalAmount: rfqLastVendorResponses.totalAmount, + vendorPaymentTermsCode: rfqLastVendorResponses.vendorPaymentTermsCode, + vendorIncotermsCode: rfqLastVendorResponses.vendorIncotermsCode, + vendorPlaceOfShipping: rfqLastVendorResponses.vendorPlaceOfShipping, + vendorDeliveryDate: rfqLastVendorResponses.vendorDeliveryDate, + vendorContractDuration: rfqLastVendorResponses.vendorContractDuration, + contractNo: rfqLastDetails.contractNo, + contractStatus: rfqLastDetails.contractStatus, + contractCreatedAt: rfqLastDetails.contractCreatedAt, + paymentTermsCode: rfqLastDetails.paymentTermsCode, + incotermsCode: rfqLastDetails.incotermsCode, + placeOfShipping: rfqLastDetails.placeOfShipping, + rfqSendDate: rfqsLast.rfqSendDate, + submittedAt: rfqLastVendorResponses.submittedAt, + picName: rfqsLast.picName, + responseStatus: rfqLastVendorResponses.status, + responseVersion: rfqLastVendorResponses.responseVersion, + }) + .from(rfqsLast) + .leftJoin(rfqLastVendorResponses, and( + eq(rfqLastVendorResponses.rfqsLastId, rfqsLast.id), + eq(rfqLastVendorResponses.vendorId, vendorId) + )) + .leftJoin(rfqLastDetails, eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetails.id)) + .leftJoin(projects, eq(rfqsLast.projectId, projects.id)) + .where(and( + advancedWhere, + globalWhere + )) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); - // RFQ 데이터에 아이템 정보 추가 - const dataWithItems = data.map(rfq => ({ - ...rfq, - items: items.filter(item => item.rfqId === rfq.id), - })); + logger.debug({ dataLength: rfqHistoryData.length }, "RFQ history data fetched"); + + // 데이터 변환 + const data = rfqHistoryData.map(row => ({ + id: row.id, + rfqType: row.rfqType, + status: row.status, + rfqCode: ((): string | null => { + if (!row.rfqCode) return null; + const rev = row.responseVersion ? ` (Rev.${row.responseVersion})` : ''; + return `${row.rfqCode}${rev}`; + })(), + projectInfo: row.projectCode && row.projectName ? `${row.projectCode} (${row.projectName})` : row.projectCode || row.projectName, + packageInfo: row.packageNo && row.packageName ? `${row.packageNo} (${row.packageName})` : row.packageNo || row.packageName, + materialInfo: row.majorItemMaterialCategory && row.majorItemMaterialDescription ? `${row.majorItemMaterialCategory} (${row.majorItemMaterialDescription})` : row.majorItemMaterialCategory || row.majorItemMaterialDescription, + // 견적정보 세부 필드들 + currency: row.vendorCurrency, + totalAmount: row.totalAmount, + leadTime: row.vendorDeliveryDate ?? row.vendorContractDuration ?? null, + paymentTerms: row.vendorPaymentTermsCode ?? row.paymentTermsCode ?? null, + incoterms: row.vendorIncotermsCode ?? row.incotermsCode ?? null, + shippingLocation: row.vendorPlaceOfShipping ?? row.placeOfShipping ?? null, + contractInfo: ((): string | null => { + const parts: string[] = []; + if (row.contractNo) parts.push(String(row.contractNo)); + if (row.contractStatus) parts.push(String(row.contractStatus)); + if (row.contractCreatedAt) parts.push(new Date(row.contractCreatedAt).toISOString().split('T')[0]); + return parts.length ? parts.join(' / ') : null; + })(), + rfqSendDate: row.rfqSendDate, + submittedAt: row.submittedAt, + picName: row.picName, + vendorStatus: row.responseStatus ?? '미응답' + })); + + // Total count 조회 - rfqsLast 기준으로 조인 (bid-history와 동일한 패턴) + const total = await tx + .select({ count: sql<number>`count(*)` }) + .from(rfqsLast) + .leftJoin(rfqLastVendorResponses, and( + eq(rfqLastVendorResponses.rfqsLastId, rfqsLast.id), + eq(rfqLastVendorResponses.vendorId, vendorId) + )) + .leftJoin(rfqLastDetails, eq(rfqLastVendorResponses.rfqLastDetailsId, rfqLastDetails.id)) + .leftJoin(projects, eq(rfqsLast.projectId, projects.id)) + .where(and( + advancedWhere, + globalWhere + )); - const total = await countRfqHistory(tx, finalWhere); - logger.debug({ total }, "RFQ history total count"); + const totalCount = total[0]?.count ?? 0; + logger.debug({ totalCount }, "RFQ history total count"); - return { data: dataWithItems, total }; - }); + return { data, total: totalCount }; + }); - const pageCount = Math.ceil(total / input.perPage); - logger.info({ - vendorId, - dataLength: data.length, - total, - pageCount - }, "RFQ history query completed"); + const pageCount = Math.ceil(total / input.perPage); + logger.info({ + vendorId, + dataLength: data.length, + total, + pageCount + }, "RFQ history query completed"); - return { data, pageCount }; - } catch (err) { - logger.error({ - err, - vendorId, - stack: err instanceof Error ? err.stack : undefined - }, 'Error fetching RFQ history'); - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify({ input, vendorId })], - { - revalidate: 3600, - tags: ["rfq-history"], - } - )(); + return { data, pageCount }; + } catch (err) { + logger.error({ + err, + vendorId, + stack: err instanceof Error ? err.stack : undefined + }, 'Error fetching RFQ history'); + return { data: [], pageCount: 0 }; + } } export async function checkJoinPortal(taxID: string) { |
