summaryrefslogtreecommitdiff
path: root/lib/general-contracts
diff options
context:
space:
mode:
Diffstat (limited to 'lib/general-contracts')
-rw-r--r--lib/general-contracts/detail/general-contract-approval-request-dialog.tsx141
-rw-r--r--lib/general-contracts/detail/general-contract-basic-info.tsx488
-rw-r--r--lib/general-contracts/detail/general-contract-communication-channel.tsx362
-rw-r--r--lib/general-contracts/detail/general-contract-detail.tsx81
-rw-r--r--lib/general-contracts/detail/general-contract-documents.tsx11
-rw-r--r--lib/general-contracts/detail/general-contract-field-service-rate.tsx288
-rw-r--r--lib/general-contracts/detail/general-contract-info-header.tsx5
-rw-r--r--lib/general-contracts/detail/general-contract-items-table.tsx292
-rw-r--r--lib/general-contracts/detail/general-contract-location.tsx480
-rw-r--r--lib/general-contracts/detail/general-contract-offset-details.tsx314
-rw-r--r--lib/general-contracts/detail/general-contract-review-comments.tsx194
-rw-r--r--lib/general-contracts/detail/general-contract-review-request-dialog.tsx891
-rw-r--r--lib/general-contracts/detail/general-contract-storage-info.tsx249
-rw-r--r--lib/general-contracts/detail/general-contract-subcontract-checklist.tsx47
-rw-r--r--lib/general-contracts/detail/general-contract-yard-entry-info.tsx232
-rw-r--r--lib/general-contracts/main/create-general-contract-dialog.tsx156
-rw-r--r--lib/general-contracts/main/general-contract-update-sheet.tsx53
-rw-r--r--lib/general-contracts/main/general-contracts-table-columns.tsx34
-rw-r--r--lib/general-contracts/main/general-contracts-table.tsx5
-rw-r--r--lib/general-contracts/service.ts1102
-rw-r--r--lib/general-contracts/types.ts8
21 files changed, 3510 insertions, 1923 deletions
diff --git a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
index f05fe9ef..25c1fb9a 100644
--- a/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
+++ b/lib/general-contracts/detail/general-contract-approval-request-dialog.tsx
@@ -27,10 +27,6 @@ import { type BasicContractTemplate } from '@/db/schema'
import {
getBasicInfo,
getContractItems,
- getCommunicationChannel,
- getLocation,
- getFieldServiceRate,
- getOffsetDetails,
getSubcontractChecklist,
uploadContractApprovalFile,
sendContractApprovalRequest
@@ -45,10 +41,6 @@ interface ContractApprovalRequestDialogProps {
interface ContractSummary {
basicInfo: Record<string, unknown>
items: Record<string, unknown>[]
- communicationChannel: Record<string, unknown> | null
- location: Record<string, unknown> | null
- fieldServiceRate: Record<string, unknown> | null
- offsetDetails: Record<string, unknown> | null
subcontractChecklist: Record<string, unknown> | null
}
@@ -280,10 +272,6 @@ export function ContractApprovalRequestDialog({
const summary: ContractSummary = {
basicInfo: {},
items: [],
- communicationChannel: null,
- location: null,
- fieldServiceRate: null,
- offsetDetails: null,
subcontractChecklist: null
}
@@ -293,6 +281,14 @@ export function ContractApprovalRequestDialog({
if (basicInfoData && basicInfoData.success) {
summary.basicInfo = basicInfoData.data || {}
}
+ // externalYardEntry 정보도 추가로 가져오기
+ const contractData = await getContractById(contractId)
+ if (contractData) {
+ summary.basicInfo = {
+ ...summary.basicInfo,
+ externalYardEntry: contractData.externalYardEntry || 'N'
+ }
+ }
} catch {
console.log('Basic Info 데이터 없음')
}
@@ -307,47 +303,6 @@ export function ContractApprovalRequestDialog({
console.log('품목 정보 데이터 없음')
}
- // 각 컴포넌트의 활성화 상태 및 데이터 확인
- try {
- // Communication Channel 확인
- const commData = await getCommunicationChannel(contractId)
- if (commData && commData.enabled) {
- summary.communicationChannel = commData
- }
- } catch {
- console.log('Communication Channel 데이터 없음')
- }
-
- try {
- // Location 확인
- const locationData = await getLocation(contractId)
- if (locationData && locationData.enabled) {
- summary.location = locationData
- }
- } catch {
- console.log('Location 데이터 없음')
- }
-
- try {
- // Field Service Rate 확인
- const fieldServiceData = await getFieldServiceRate(contractId)
- if (fieldServiceData && fieldServiceData.enabled) {
- summary.fieldServiceRate = fieldServiceData
- }
- } catch {
- console.log('Field Service Rate 데이터 없음')
- }
-
- try {
- // Offset Details 확인
- const offsetData = await getOffsetDetails(contractId)
- if (offsetData && offsetData.enabled) {
- summary.offsetDetails = offsetData
- }
- } catch {
- console.log('Offset Details 데이터 없음')
- }
-
try {
// Subcontract Checklist 확인
const subcontractData = await getSubcontractChecklist(contractId)
@@ -943,86 +898,6 @@ export function ContractApprovalRequestDialog({
)}
</div>
- {/* 커뮤니케이션 채널 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="communication-enabled"
- checked={!!contractSummary?.communicationChannel}
- disabled
- />
- <Label htmlFor="communication-enabled" className="font-medium">
- 커뮤니케이션 채널
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.communicationChannel
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
-
- {/* 위치 정보 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="location-enabled"
- checked={!!contractSummary?.location}
- disabled
- />
- <Label htmlFor="location-enabled" className="font-medium">
- 위치 정보
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.location
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
-
- {/* 현장 서비스 요율 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="fieldService-enabled"
- checked={!!contractSummary?.fieldServiceRate}
- disabled
- />
- <Label htmlFor="fieldService-enabled" className="font-medium">
- 현장 서비스 요율
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.fieldServiceRate
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
-
- {/* 오프셋 세부사항 */}
- <div className="border rounded-lg p-4">
- <div className="flex items-center gap-2 mb-3">
- <Checkbox
- id="offset-enabled"
- checked={!!contractSummary?.offsetDetails}
- disabled
- />
- <Label htmlFor="offset-enabled" className="font-medium">
- 오프셋 세부사항
- </Label>
- <Badge variant="outline">선택</Badge>
- </div>
- <p className="text-sm text-muted-foreground">
- {contractSummary?.offsetDetails
- ? '정보가 입력되어 있습니다.'
- : '정보가 입력되지 않았습니다.'}
- </p>
- </div>
-
{/* 하도급 체크리스트 */}
<div className="border rounded-lg p-4">
<div className="flex items-center gap-2 mb-3">
diff --git a/lib/general-contracts/detail/general-contract-basic-info.tsx b/lib/general-contracts/detail/general-contract-basic-info.tsx
index d891fe63..4071b2e0 100644
--- a/lib/general-contracts/detail/general-contract-basic-info.tsx
+++ b/lib/general-contracts/detail/general-contract-basic-info.tsx
@@ -16,6 +16,11 @@ import { GeneralContract } from '@/db/schema'
import { ContractDocuments } from './general-contract-documents'
import { getPaymentTermsForSelection, getIncotermsForSelection, getPlaceOfShippingForSelection, getPlaceOfDestinationForSelection } from '@/lib/procurement-select/service'
import { TAX_CONDITIONS, getTaxConditionName } from '@/lib/tax-conditions/types'
+import { GENERAL_CONTRACT_SCOPES } from '@/lib/general-contracts/types'
+import { uploadContractAttachment, getContractAttachments, deleteContractAttachment, getContractAttachmentForDownload } from '../service'
+import { downloadFile } from '@/lib/file-download'
+import { FileText, Upload, Download, Trash2 } from 'lucide-react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
interface ContractBasicInfoProps {
contractId: number
@@ -38,6 +43,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
const [procurementLoading, setProcurementLoading] = useState(false)
const [formData, setFormData] = useState({
+ contractScope: '', // 계약확정범위
specificationType: '',
specificationManualText: '',
unitPriceType: '',
@@ -83,9 +89,16 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
projectNotAwarded: false,
other: false,
},
+ externalYardEntry: 'N' as 'Y' | 'N', // 사외업체 야드투입 (Y/N)
+ contractAmountReason: '', // 합의계약 미확정 사유
})
const [errors] = useState<Record<string, string>>({})
+ const [specificationFiles, setSpecificationFiles] = useState<Array<{ id: number; fileName: string; filePath: string; uploadedAt: Date }>>([])
+ const [isLoadingSpecFiles, setIsLoadingSpecFiles] = useState(false)
+ const [showSpecFileDialog, setShowSpecFileDialog] = useState(false)
+ const [unitPriceTypeOther, setUnitPriceTypeOther] = useState<string>('') // 단가 유형 '기타' 수기입력
+ const [showYardEntryConfirmDialog, setShowYardEntryConfirmDialog] = useState(false)
// 계약 데이터 로드
React.useEffect(() => {
@@ -121,7 +134,13 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
setPaymentDeliveryPercent(paymentDeliveryPercentValue)
+ // 합의계약(AD, AW)인 경우 인도조건 기본값 설정
+ const defaultDeliveryTerm = (contractData?.type === 'AD' || contractData?.type === 'AW')
+ ? '본 표준하도급 계약에 따름'
+ : (contractData?.deliveryTerm || '')
+
setFormData({
+ contractScope: contractData?.contractScope || '',
specificationType: contractData?.specificationType || '',
specificationManualText: contractData?.specificationManualText || '',
unitPriceType: contractData?.unitPriceType || '',
@@ -145,10 +164,11 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
liquidatedDamages: Boolean(contractData?.liquidatedDamages),
liquidatedDamagesPercent: contractData?.liquidatedDamagesPercent || '',
deliveryType: contractData?.deliveryType || '',
- deliveryTerm: contractData?.deliveryTerm || '',
+ deliveryTerm: defaultDeliveryTerm,
shippingLocation: contractData?.shippingLocation || '',
dischargeLocation: contractData?.dischargeLocation || '',
contractDeliveryDate: contractData?.contractDeliveryDate || '',
+ paymentDeliveryAdditionalText: (contractData as any)?.paymentDeliveryAdditionalText || '',
contractEstablishmentConditions: contractEstablishmentConditions || {
regularVendorRegistration: false,
projectAward: false,
@@ -167,6 +187,8 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
projectNotAwarded: false,
other: false,
},
+ externalYardEntry: (contractData?.externalYardEntry as 'Y' | 'N') || 'N',
+ contractAmountReason: (contractData as any)?.contractAmountReason || '',
})
} catch (error) {
console.error('Error loading contract:', error)
@@ -179,6 +201,33 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
}
}, [contractId])
+ // 사양 파일 목록 로드
+ React.useEffect(() => {
+ const loadSpecificationFiles = async () => {
+ if (!contractId || formData.specificationType !== '첨부서류 참조') return
+
+ setIsLoadingSpecFiles(true)
+ try {
+ const attachments = await getContractAttachments(contractId)
+ const specFiles = (attachments as Array<{ id: number; fileName: string; filePath: string; documentName: string; uploadedAt: Date }>)
+ .filter(att => att.documentName === '사양 및 공급범위' || att.documentName === 'specification')
+ .map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ filePath: att.filePath,
+ uploadedAt: att.uploadedAt
+ }))
+ setSpecificationFiles(specFiles)
+ } catch (error) {
+ console.error('Error loading specification files:', error)
+ } finally {
+ setIsLoadingSpecFiles(false)
+ }
+ }
+
+ loadSpecificationFiles()
+ }, [contractId, formData.specificationType])
+
// Procurement 데이터 로드 함수들
const loadPaymentTerms = React.useCallback(async () => {
setProcurementLoading(true);
@@ -249,7 +298,16 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
// 필수값 validation 체크
const validationErrors: string[] = []
+ if (!formData.contractScope) validationErrors.push('계약확정범위')
if (!formData.specificationType) validationErrors.push('사양')
+ // 첨부서류 참조 선택 시 사양 파일 필수 체크
+ if (formData.specificationType === '첨부서류 참조' && specificationFiles.length === 0) {
+ validationErrors.push('사양 파일')
+ }
+ // LO 계약인 경우 계약체결유효기간 필수값 체크
+ if (contract?.type === 'LO' && !contract?.validityEndDate) {
+ validationErrors.push('계약체결유효기간')
+ }
if (!formData.paymentDelivery) validationErrors.push('납품 지급조건')
if (!formData.currency) validationErrors.push('계약통화')
if (!formData.paymentTerm) validationErrors.push('지불조건')
@@ -294,6 +352,35 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
{/* 기본 정보 탭 */}
<TabsContent value="basic" className="space-y-6">
+ {/* 계약확정범위 */}
+ <Card>
+ <CardHeader>
+ <CardTitle>계약확정범위</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid gap-4">
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="contractScope">계약확정범위 *</Label>
+ <Select
+ value={formData.contractScope}
+ onValueChange={(value) => setFormData(prev => ({ ...prev, contractScope: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="계약확정범위 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {GENERAL_CONTRACT_SCOPES.map((scope) => (
+ <SelectItem key={scope} value={scope}>
+ {scope}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
<Card>
{/* 보증기간 및 단가유형 */}
<CardHeader>
@@ -509,7 +596,7 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<SelectValue placeholder="사양을 선택하세요" />
</SelectTrigger>
<SelectContent>
- <SelectItem value="첨부파일">첨부파일</SelectItem>
+ <SelectItem value="첨부서류 참조">첨부서류 참조</SelectItem>
<SelectItem value="표준사양">표준사양</SelectItem>
<SelectItem value="수기사양">수기사양</SelectItem>
</SelectContent>
@@ -520,9 +607,26 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
</div>
{/* 단가 */}
<div className="flex flex-col gap-2">
- <Label htmlFor="unitPriceType">단가 유형</Label>
+ <Label htmlFor="unitPriceType">
+ 단가 유형
+ {(() => {
+ const contractType = contract?.type as string || ''
+ const contractCategory = contract?.category as string || ''
+ const unitPriceContractTypes = ['UP', 'LE', 'IL', 'AL', 'OS', 'OW']
+ const isUnitPriceRequired = contractCategory === 'unit_price' && unitPriceContractTypes.includes(contractType)
+ return isUnitPriceRequired ? <span className="text-red-600 ml-1">*</span> : null
+ })()}
+ </Label>
<Select value={formData.unitPriceType} onValueChange={(value) => setFormData(prev => ({ ...prev, unitPriceType: value }))}>
- <SelectTrigger>
+ <SelectTrigger className={
+ (() => {
+ const contractType = contract?.type as string || ''
+ const contractCategory = contract?.category as string || ''
+ const unitPriceContractTypes = ['UP', 'LE', 'IL', 'AL', 'OS', 'OW']
+ const isUnitPriceRequired = contractCategory === 'unit_price' && unitPriceContractTypes.includes(contractType)
+ return isUnitPriceRequired && !formData.unitPriceType ? 'border-red-500' : ''
+ })()
+ }>
<SelectValue placeholder="단가 유형을 선택하세요" />
</SelectTrigger>
<SelectContent>
@@ -535,6 +639,29 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<SelectItem value="기타">기타</SelectItem>
</SelectContent>
</Select>
+ {/* 단가 유형 '기타' 선택 시 수기입력 필드 */}
+ {formData.unitPriceType === '기타' && (
+ <div className="mt-2">
+ <Input
+ value={unitPriceTypeOther}
+ onChange={(e) => setUnitPriceTypeOther(e.target.value)}
+ placeholder="단가 유형을 수기로 입력하세요"
+ className="mt-2"
+ required
+ />
+ </div>
+ )}
+ {(() => {
+ const contractType = contract?.type as string || ''
+ const contractCategory = contract?.category as string || ''
+ const unitPriceContractTypes = ['UP', 'LE', 'IL', 'AL', 'OS', 'OW']
+ const isUnitPriceRequired = contractCategory === 'unit_price' && unitPriceContractTypes.includes(contractType)
+ return isUnitPriceRequired && !formData.unitPriceType ? (
+ <p className="text-sm text-red-600">단가 유형은 필수값입니다.</p>
+ ) : formData.unitPriceType === '기타' && !unitPriceTypeOther.trim() ? (
+ <p className="text-sm text-red-600">단가 유형(기타)을 입력해주세요.</p>
+ ) : null
+ })()}
</div>
{/* 선택에 따른 폼: vertical로 출력 */}
@@ -552,6 +679,187 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
</div>
)}
+ {/* 사양이 첨부서류 참조일 때 파일 업로드 */}
+ {formData.specificationType === '첨부서류 참조' && (
+ <div className="flex flex-col gap-2">
+ <div className="flex items-center justify-between">
+ <Label htmlFor="specificationFile">
+ 사양 파일 <span className="text-red-600">*</span>
+ </Label>
+ <Dialog open={showSpecFileDialog} onOpenChange={setShowSpecFileDialog}>
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" type="button">
+ <Upload className="h-4 w-4 mr-2" />
+ 파일 업로드
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-2xl">
+ <DialogHeader>
+ <DialogTitle>사양 파일 업로드</DialogTitle>
+ </DialogHeader>
+ <div className="space-y-4">
+ <div>
+ <Label htmlFor="file-upload">파일 선택</Label>
+ <Input
+ id="file-upload"
+ type="file"
+ onChange={async (e) => {
+ const file = e.target.files?.[0]
+ if (!file || !userId) return
+
+ try {
+ setIsLoadingSpecFiles(true)
+ const result = await uploadContractAttachment(
+ contractId,
+ file,
+ userId.toString(),
+ '사양 및 공급범위'
+ )
+
+ if (result.success) {
+ toast.success('사양 파일이 업로드되었습니다.')
+ // 파일 목록 새로고침
+ const attachments = await getContractAttachments(contractId)
+ const specFiles = (attachments as Array<{ id: number; fileName: string; filePath: string; documentName: string; uploadedAt: Date }>)
+ .filter(att => att.documentName === '사양 및 공급범위' || att.documentName === 'specification')
+ .map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ filePath: att.filePath,
+ uploadedAt: att.uploadedAt
+ }))
+ setSpecificationFiles(specFiles)
+ setShowSpecFileDialog(false)
+ e.target.value = ''
+ } else {
+ toast.error(result.error || '파일 업로드에 실패했습니다.')
+ }
+ } catch (error) {
+ console.error('Error uploading file:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoadingSpecFiles(false)
+ }
+ }}
+ disabled={isLoadingSpecFiles}
+ />
+ </div>
+
+ {/* 업로드된 파일 목록 */}
+ {specificationFiles.length > 0 && (
+ <div className="space-y-2">
+ <Label>업로드된 파일</Label>
+ <div className="space-y-2 max-h-60 overflow-y-auto">
+ {specificationFiles.map((file) => (
+ <div key={file.id} className="flex items-center justify-between p-2 border rounded">
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm">{file.fileName}</span>
+ <span className="text-xs text-muted-foreground">
+ ({new Date(file.uploadedAt).toLocaleDateString()})
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={async () => {
+ try {
+ const fileData = await getContractAttachmentForDownload(file.id, contractId)
+ downloadFile(fileData.attachment?.filePath || '', fileData.attachment?.fileName || '', {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Error downloading file:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={async () => {
+ try {
+ await deleteContractAttachment(file.id, contractId)
+ toast.success('파일이 삭제되었습니다.')
+ setSpecificationFiles(prev => prev.filter(f => f.id !== file.id))
+ } catch (error) {
+ console.error('Error deleting file:', error)
+ toast.error('파일 삭제 중 오류가 발생했습니다.')
+ }
+ }}
+ className="text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ </div>
+
+ {specificationFiles.length === 0 && (
+ <p className="text-sm text-red-600">사양 파일을 업로드해주세요.</p>
+ )}
+
+ {specificationFiles.length > 0 && (
+ <div className="space-y-2">
+ {specificationFiles.map((file) => (
+ <div key={file.id} className="flex items-center justify-between p-2 border rounded text-sm">
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span>{file.fileName}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={async () => {
+ try {
+ const fileData = await getContractAttachmentForDownload(file.id, contractId)
+ downloadFile(fileData.attachment?.filePath || '', fileData.attachment?.fileName || '', {
+ showToast: true
+ })
+ } catch (error) {
+ console.error('Error downloading file:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={async () => {
+ try {
+ await deleteContractAttachment(file.id, contractId)
+ toast.success('파일이 삭제되었습니다.')
+ setSpecificationFiles(prev => prev.filter(f => f.id !== file.id))
+ } catch (error) {
+ console.error('Error deleting file:', error)
+ toast.error('파일 삭제 중 오류가 발생했습니다.')
+ }
+ }}
+ className="text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+
</div>
@@ -664,6 +972,37 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
disabled={!formData.paymentBeforeDelivery.materialPurchase}
/>
</div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id="additionalConditionBefore"
+ checked={formData.paymentBeforeDelivery.additionalCondition || false}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ additionalCondition: e.target.checked
+ }
+ }))}
+ className="rounded"
+ />
+ <Label htmlFor="additionalConditionBefore" className="text-sm">추가조건</Label>
+ <Input
+ type="number"
+ min="0"
+ placeholder="%"
+ className="w-16"
+ value={formData.paymentBeforeDelivery.additionalConditionPercent || ''}
+ onChange={(e) => setFormData(prev => ({
+ ...prev,
+ paymentBeforeDelivery: {
+ ...prev.paymentBeforeDelivery,
+ additionalConditionPercent: e.target.value
+ }
+ }))}
+ disabled={!formData.paymentBeforeDelivery.additionalCondition}
+ />
+ </div>
</div>
</div>
@@ -678,26 +1017,26 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<SelectValue placeholder="납품 지급조건을 선택하세요" />
</SelectTrigger>
<SelectContent>
- <SelectItem value="L/C">L/C</SelectItem>
- <SelectItem value="T/T">T/T</SelectItem>
- <SelectItem value="거래명세서 기반 정기지급조건">거래명세서 기반 정기지급조건</SelectItem>
- <SelectItem value="작업 및 입고 검사 완료">작업 및 입고 검사 완료</SelectItem>
- <SelectItem value="청구내역서 제출 및 승인">청구내역서 제출 및 승인</SelectItem>
- <SelectItem value="정규금액 월 단위 정산(지정일 지급)">정규금액 월 단위 정산(지정일 지급)</SelectItem>
+ {/* Payment term 검색 옵션들 */}
+ {paymentTermsOptions.map((term) => (
+ <SelectItem key={term.code} value={term.code}>
+ {term.code} - {term.description}
+ </SelectItem>
+ ))}
+ <SelectItem value="납품완료일로부터 60일 이내 지급">납품완료일로부터 60일 이내 지급</SelectItem>
+ <SelectItem value="추가조건">추가조건</SelectItem>
</SelectContent>
</Select>
- {/* L/C 또는 T/T 선택 시 퍼센트 입력 필드 */}
- {(formData.paymentDelivery === 'L/C' || formData.paymentDelivery === 'T/T') && (
- <div className="flex items-center gap-2 mt-2">
+ {/* 추가조건 선택 시 수기 입력 필드 */}
+ {formData.paymentDelivery === '추가조건' && (
+ <div className="mt-2">
<Input
- type="number"
- min="0"
- value={paymentDeliveryPercent}
- onChange={(e) => setPaymentDeliveryPercent(e.target.value)}
- placeholder="퍼센트"
- className="w-20 h-8 text-sm"
+ type="text"
+ value={formData.paymentDeliveryAdditionalText || ''}
+ onChange={(e) => setFormData(prev => ({ ...prev, paymentDeliveryAdditionalText: e.target.value }))}
+ placeholder="추가조건을 입력하세요"
+ className="w-full"
/>
- <span className="text-sm text-gray-600">%</span>
</div>
)}
{errors.paymentDelivery && (
@@ -1036,13 +1375,34 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="contractAmount">계약금액 (자동계산)</Label>
- <Input
- type="text"
- value={contract?.contractAmount ? new Intl.NumberFormat('ko-KR').format(Number(contract.contractAmount)) : '품목정보 없음'}
- readOnly
- className="bg-gray-50"
- placeholder="품목정보에서 자동 계산됩니다"
- />
+ {contract?.type === 'AD' || contract?.type === 'AW' ? (
+ <div className="space-y-2">
+ <Input
+ type="text"
+ value="미확정"
+ readOnly
+ className="bg-gray-50"
+ />
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="contractAmountReason">미확정 사유</Label>
+ <Textarea
+ id="contractAmountReason"
+ value={formData.contractAmountReason}
+ onChange={(e) => setFormData(prev => ({ ...prev, contractAmountReason: e.target.value }))}
+ placeholder="계약금액 미확정 사유를 입력하세요"
+ rows={3}
+ />
+ </div>
+ </div>
+ ) : (
+ <Input
+ type="text"
+ value={contract?.contractAmount ? new Intl.NumberFormat('ko-KR').format(Number(contract.contractAmount)) : '품목정보 없음'}
+ readOnly
+ className="bg-gray-50"
+ placeholder="품목정보에서 자동 계산됩니다"
+ />
+ )}
</div>
<div className="space-y-2">
<Label htmlFor="currency">계약통화 <span className="text-red-600">*</span></Label>
@@ -1058,6 +1418,80 @@ export function ContractBasicInfo({ contractId }: ContractBasicInfoProps) {
)}
</div>
+ {/* 사외업체 야드투입 */}
+ <div className="space-y-4 col-span-2">
+ <Label className="text-base font-medium">사외업체 야드투입</Label>
+ <div className="flex items-center space-x-4">
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="yardEntryYes"
+ name="externalYardEntry"
+ value="Y"
+ checked={formData.externalYardEntry === 'Y'}
+ onChange={(e) => setFormData(prev => ({ ...prev, externalYardEntry: 'Y' as 'Y' | 'N' }))}
+ className="rounded"
+ />
+ <Label htmlFor="yardEntryYes">Y</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <input
+ type="radio"
+ id="yardEntryNo"
+ name="externalYardEntry"
+ value="N"
+ checked={formData.externalYardEntry === 'N'}
+ onChange={(e) => {
+ // 이전 값이 'Y'였고 'N'으로 변경하는 경우 팝업 표시
+ if (formData.externalYardEntry === 'Y') {
+ setShowYardEntryConfirmDialog(true)
+ } else {
+ setFormData(prev => ({ ...prev, externalYardEntry: 'N' as 'Y' | 'N' }))
+ }
+ }}
+ className="rounded"
+ />
+ <Label htmlFor="yardEntryNo">N</Label>
+ </div>
+ </div>
+ {/* 사외업체 야드투입 'N' 선택 시 확인 팝업 */}
+ <Dialog open={showYardEntryConfirmDialog} onOpenChange={setShowYardEntryConfirmDialog}>
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>안전필수사항 확인</DialogTitle>
+ </DialogHeader>
+ <div className="space-y-4">
+ <p className="text-sm text-muted-foreground">
+ 안전필수사항으로 사내작업여부를 재확인 바랍니다.
+ </p>
+ <div className="flex justify-end gap-2">
+ <Button
+ variant="outline"
+ onClick={() => {
+ setShowYardEntryConfirmDialog(false)
+ // 라디오 버튼을 다시 'Y'로 되돌림
+ const yardEntryYesRadio = document.getElementById('yardEntryYes') as HTMLInputElement
+ if (yardEntryYesRadio) {
+ yardEntryYesRadio.checked = true
+ }
+ }}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={() => {
+ setFormData(prev => ({ ...prev, externalYardEntry: 'N' as 'Y' | 'N' }))
+ setShowYardEntryConfirmDialog(false)
+ }}
+ >
+ 확인
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
+ </div>
+
{/* 계약성립조건 */}
<div className="space-y-4 col-span-2">
<Label className="text-base font-medium">계약성립조건</Label>
diff --git a/lib/general-contracts/detail/general-contract-communication-channel.tsx b/lib/general-contracts/detail/general-contract-communication-channel.tsx
deleted file mode 100644
index f5cd79b2..00000000
--- a/lib/general-contracts/detail/general-contract-communication-channel.tsx
+++ /dev/null
@@ -1,362 +0,0 @@
-'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Plus, Trash2, Save, LoaderIcon, MessageSquare } from 'lucide-react'
-import { updateCommunicationChannel, getCommunicationChannel } from '../service'
-import { toast } from 'sonner'
-
-interface CommunicationChannelProps {
- contractType?: string
- contractId: number
-}
-
-interface Representative {
- id: string
- position: string
- name: string
- telNo: string
- email: string
- isActive: boolean
-}
-
-export function CommunicationChannel({ contractId }: CommunicationChannelProps) {
- const session = useSession()
- const [isLoading, setIsLoading] = useState(false)
- const [isEnabled, setIsEnabled] = useState(true)
-
- // 일단 모든 계약종류에서 활성화
- const isDisabled = false
-
- const [contractorReps, setContractorReps] = useState<Representative[]>([])
- const [supplierReps, setSupplierReps] = useState<Representative[]>([])
-
- // 초기 데이터 로드
- useEffect(() => {
- const loadCommunicationChannel = async () => {
- try {
- const data = await getCommunicationChannel(contractId)
- if (data && data.enabled !== undefined) {
- setIsEnabled(data.enabled)
- setContractorReps(data.contractorRepresentatives || [])
- setSupplierReps(data.supplierRepresentatives || [])
- }
- } catch (error) {
- console.error('Error loading communication channel:', error)
-
- }
- }
-
- loadCommunicationChannel()
- }, [contractId])
-
- const addContractorRow = () => {
- const newId = (contractorReps.length + 1).toString()
- setContractorReps([...contractorReps, {
- id: newId,
- position: '',
- name: '',
- telNo: '',
- email: '',
- isActive: false
- }])
- }
-
- const removeContractorRow = () => {
- const selectedRows = contractorReps.filter(rep => rep.isActive)
- if (selectedRows.length > 0) {
- setContractorReps(contractorReps.filter(rep => !rep.isActive))
- }
- }
-
- const addSupplierRow = () => {
- const newId = (supplierReps.length + 1).toString()
- setSupplierReps([...supplierReps, {
- id: newId,
- position: '',
- name: '',
- telNo: '',
- email: '',
- isActive: false
- }])
- }
-
- const removeSupplierRow = () => {
- const selectedRows = supplierReps.filter(rep => rep.isActive)
- if (selectedRows.length > 0) {
- setSupplierReps(supplierReps.filter(rep => !rep.isActive))
- }
- }
-
- const updateContractorRep = (id: string, field: keyof Representative, value: string | boolean) => {
- setContractorReps(contractorReps.map(rep =>
- rep.id === id ? { ...rep, [field]: value } : rep
- ))
- }
-
- const updateSupplierRep = (id: string, field: keyof Representative, value: string | boolean) => {
- setSupplierReps(supplierReps.map(rep =>
- rep.id === id ? { ...rep, [field]: value } : rep
- ))
- }
-
- const handleSaveCommunicationChannel = async () => {
- const userId = session.data?.user?.id ? Number(session.data.user.id) : null
-
- if (!userId) {
- toast.error('사용자 정보를 찾을 수 없습니다.')
- return
- }
-
- try {
- setIsLoading(true)
-
- const communicationData = {
- enabled: isEnabled,
- contractorRepresentatives: contractorReps,
- supplierRepresentatives: supplierReps
- }
-
- await updateCommunicationChannel(contractId, communicationData, userId)
- toast.success('커뮤니케이션 채널이 저장되었습니다.')
- } catch (error) {
- console.error('Error saving communication channel:', error)
- toast.error('커뮤니케이션 채널 저장에 실패했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- return (
- <div className="w-full">
- <Accordion type="single" collapsible className="w-full">
- {/* Communication Channel 활성화 */}
- <AccordionItem value="communication-channel">
- <AccordionTrigger className="hover:no-underline">
- <div className="flex items-center gap-3 w-full">
- <MessageSquare className="w-5 h-5" />
- <span className="font-medium">Communication Channel</span>
- </div>
- </AccordionTrigger>
- <AccordionContent>
- <div className="space-y-6">
- {/* 체크박스 */}
- <div className="flex items-center gap-2">
- <Checkbox
- checked={isEnabled}
- disabled={isDisabled}
- onCheckedChange={(checked) => {
- if (!isDisabled) {
- setIsEnabled(checked as boolean)
- }
- }}
- />
- <span className="text-sm font-medium">Communication Channel 활성화</span>
- </div>
-
- {/* Table 1: The Contractor's Representatives */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-lg font-medium">Table 1: The Contractor &apos;s Representatives</h3>
- <div className="flex gap-2">
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={addContractorRow}
- disabled={isDisabled || !isEnabled}
- >
- <Plus className="w-4 h-4 mr-1" />
- 행 추가
- </Button>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={removeContractorRow}
- disabled={isDisabled || !isEnabled}
- >
- <Trash2 className="w-4 h-4 mr-1" />
- 행 삭제
- </Button>
- </div>
- </div>
-
- <div className="overflow-x-auto">
- <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
- <thead>
- <tr className="bg-yellow-100">
- <th className="border border-gray-300 p-2 w-12"></th>
- <th className="border border-gray-300 p-2 w-16">No.</th>
- <th className="border border-gray-300 p-2">Position</th>
- <th className="border border-gray-300 p-2">Name</th>
- <th className="border border-gray-300 p-2">Tel. No.</th>
- <th className="border border-gray-300 p-2">Email</th>
- </tr>
- </thead>
- <tbody>
- {contractorReps.map((rep) => (
- <tr key={rep.id} className="bg-yellow-50">
- <td className="border border-gray-300 p-2 text-center">
- <Checkbox
- checked={rep.isActive}
- onCheckedChange={(checked) => updateContractorRep(rep.id, 'isActive', checked as boolean)}
- disabled={isDisabled || !isEnabled}
- />
- </td>
- <td className="border border-gray-300 p-2 text-center">{rep.id}</td>
- <td className="border border-gray-300 p-2">
- <Input
- value={rep.position}
- onChange={(e) => updateContractorRep(rep.id, 'position', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={rep.name}
- onChange={(e) => updateContractorRep(rep.id, 'name', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={rep.telNo}
- onChange={(e) => updateContractorRep(rep.id, 'telNo', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={rep.email}
- onChange={(e) => updateContractorRep(rep.id, 'email', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </div>
-
- {/* Table 2: The Supplier's Representatives */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-lg font-medium">Table 2: The Supplier &apos;s Representatives</h3>
- <div className="flex gap-2">
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={addSupplierRow}
- disabled={isDisabled || !isEnabled}
- >
- <Plus className="w-4 h-4 mr-1" />
- 행 추가
- </Button>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={removeSupplierRow}
- disabled={isDisabled || !isEnabled}
- >
- <Trash2 className="w-4 h-4 mr-1" />
- 행 삭제
- </Button>
- </div>
- </div>
-
- <div className="overflow-x-auto">
- <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
- <thead>
- <tr className="bg-yellow-100">
- <th className="border border-gray-300 p-2 w-12"></th>
- <th className="border border-gray-300 p-2 w-16">No.</th>
- <th className="border border-gray-300 p-2">Position</th>
- <th className="border border-gray-300 p-2">Name</th>
- <th className="border border-gray-300 p-2">Tel. No.</th>
- <th className="border border-gray-300 p-2">Email</th>
- </tr>
- </thead>
- <tbody>
- {supplierReps.map((rep) => (
- <tr key={rep.id} className="bg-yellow-50">
- <td className="border border-gray-300 p-2 text-center">
- <Checkbox
- checked={rep.isActive}
- onCheckedChange={(checked) => updateSupplierRep(rep.id, 'isActive', checked as boolean)}
- disabled={isDisabled || !isEnabled}
- />
- </td>
- <td className="border border-gray-300 p-2 text-center">{rep.id}</td>
- <td className="border border-gray-300 p-2">
- <Input
- value={rep.position}
- onChange={(e) => updateSupplierRep(rep.id, 'position', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={rep.name}
- onChange={(e) => updateSupplierRep(rep.id, 'name', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={rep.telNo}
- onChange={(e) => updateSupplierRep(rep.id, 'telNo', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={rep.email}
- onChange={(e) => updateSupplierRep(rep.id, 'email', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </div>
-
- {/* 저장 버튼 */}
- <div className="flex justify-end pt-4 border-t">
- <Button
- onClick={handleSaveCommunicationChannel}
- disabled={isLoading || isDisabled || !isEnabled}
- className="flex items-center gap-2"
- >
- {isLoading ? (
- <LoaderIcon className="w-4 h-4 animate-spin" />
- ) : (
- <Save className="w-4 h-4" />
- )}
- 커뮤니케이션 채널 저장
- </Button>
- </div>
- </div>
- </AccordionContent>
- </AccordionItem>
- </Accordion>
- </div>
- )
-}
diff --git a/lib/general-contracts/detail/general-contract-detail.tsx b/lib/general-contracts/detail/general-contract-detail.tsx
index 8e7a7aff..f2a916f8 100644
--- a/lib/general-contracts/detail/general-contract-detail.tsx
+++ b/lib/general-contracts/detail/general-contract-detail.tsx
@@ -12,11 +12,11 @@ import { Skeleton } from '@/components/ui/skeleton'
import { ContractItemsTable } from './general-contract-items-table'
import { SubcontractChecklist } from './general-contract-subcontract-checklist'
import { ContractBasicInfo } from './general-contract-basic-info'
-import { CommunicationChannel } from './general-contract-communication-channel'
-import { Location } from './general-contract-location'
-import { FieldServiceRate } from './general-contract-field-service-rate'
-import { OffsetDetails } from './general-contract-offset-details'
import { ContractApprovalRequestDialog } from './general-contract-approval-request-dialog'
+import { ContractStorageInfo } from './general-contract-storage-info'
+import { ContractYardEntryInfo } from './general-contract-yard-entry-info'
+import { ContractReviewComments } from './general-contract-review-comments'
+import { ContractReviewRequestDialog } from './general-contract-review-request-dialog'
export default function ContractDetailPage() {
const params = useParams()
@@ -26,7 +26,8 @@ export default function ContractDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showApprovalDialog, setShowApprovalDialog] = useState(false)
- const [subcontractChecklistData, setSubcontractChecklistData] = useState<any>(null)
+ const [subcontractChecklistData, setSubcontractChecklistData] = useState<Record<string, unknown> | null>(null)
+ const [showReviewDialog, setShowReviewDialog] = useState(false)
useEffect(() => {
const fetchContract = async () => {
@@ -110,13 +111,24 @@ export default function ContractDetailPage() {
</p>
</div>
<div className="flex gap-2">
+ {/* 조건검토요청 버튼 - Draft 상태일 때만 표시 */}
+ {contract?.status === 'Draft' && (
+ <Button
+ onClick={() => setShowReviewDialog(true)}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ 조건검토요청
+ </Button>
+ )}
{/* 계약승인요청 버튼 */}
- <Button
- onClick={() => setShowApprovalDialog(true)}
- className="bg-blue-600 hover:bg-blue-700"
- >
- 계약승인요청
- </Button>
+ <>
+ <Button
+ onClick={() => setShowApprovalDialog(true)}
+ className="bg-blue-600 hover:bg-blue-700"
+ >
+ 계약승인요청
+ </Button>
+ </>
{/* 계약목록으로 돌아가기 버튼 */}
<Button asChild variant="outline" size="sm">
<Link href="/evcp/general-contracts">
@@ -150,7 +162,8 @@ export default function ContractDetailPage() {
onItemsChange={() => {}}
onTotalAmountChange={() => {}}
availableBudget={0}
- readOnly={contract?.contractScope === '단가' || contract?.contractScope === '물량(실적)'}
+ readOnly={false}
+ contractScope={contract?.contractScope as string || ''}
/>
{/* 하도급법 자율점검 체크리스트 */}
<SubcontractChecklist
@@ -158,18 +171,31 @@ export default function ContractDetailPage() {
onDataChange={(data) => setSubcontractChecklistData(data)}
readOnly={false}
initialData={subcontractChecklistData}
+ contractType={contract?.type as string || ''}
+ vendorCountry={(contract as any)?.vendorCountry || 'KR'}
+ />
+
+ {/* 임치(물품보관)계약 상세 정보 - SG 계약종류일 때만 표시 */}
+ {contract?.type === 'SG' && (
+ <ContractStorageInfo
+ contractId={contract.id as number}
+ readOnly={false}
+ />
+ )}
+
+ {/* 사외업체 야드투입 정보 - externalYardEntry가 'Y'일 때만 표시 */}
+ {contract?.externalYardEntry === 'Y' && (
+ <ContractYardEntryInfo
+ contractId={contract.id as number}
+ readOnly={false}
+ />
+ )}
+
+ {/* 계약 조건 검토 의견 섹션 */}
+ <ContractReviewComments
+ contractId={contract.id as number}
+ contractStatus={contract.status as string}
/>
- {/* Communication Channel */}
- <CommunicationChannel contractId={Number(contract.id)} />
-
- {/* Location */}
- <Location contractId={Number(contract.id)} />
-
- {/* Field Service Rate */}
- <FieldServiceRate contractId={Number(contract.id)} />
-
- {/* Offset Details */}
- <OffsetDetails contractId={Number(contract.id)} />
</div>
)}
@@ -181,6 +207,15 @@ export default function ContractDetailPage() {
onOpenChange={setShowApprovalDialog}
/>
)}
+
+ {/* 조건검토요청 다이얼로그 */}
+ {contract && (
+ <ContractReviewRequestDialog
+ contract={contract}
+ open={showReviewDialog}
+ onOpenChange={setShowReviewDialog}
+ />
+ )}
</div>
)
}
diff --git a/lib/general-contracts/detail/general-contract-documents.tsx b/lib/general-contracts/detail/general-contract-documents.tsx
index b0f20e7f..ee2af8a2 100644
--- a/lib/general-contracts/detail/general-contract-documents.tsx
+++ b/lib/general-contracts/detail/general-contract-documents.tsx
@@ -22,7 +22,8 @@ import {
uploadContractAttachment,
getContractAttachments,
getContractAttachmentForDownload,
- deleteContractAttachment
+ deleteContractAttachment,
+ saveContractAttachmentComment
} from '../service'
import { downloadFile } from '@/lib/file-download'
@@ -138,7 +139,13 @@ export function ContractDocuments({ contractId, userId, readOnly = false }: Cont
if (!editingComment) return
try {
- // TODO: API 호출로 댓글 저장
+ await saveContractAttachmentComment(
+ editingComment.id,
+ contractId,
+ editingComment.type,
+ commentText,
+ Number(userId)
+ )
toast.success('댓글이 저장되었습니다.')
setEditingComment(null)
setCommentText('')
diff --git a/lib/general-contracts/detail/general-contract-field-service-rate.tsx b/lib/general-contracts/detail/general-contract-field-service-rate.tsx
deleted file mode 100644
index a8158307..00000000
--- a/lib/general-contracts/detail/general-contract-field-service-rate.tsx
+++ /dev/null
@@ -1,288 +0,0 @@
-'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Plus, Trash2, Save, LoaderIcon, DollarSign } from 'lucide-react'
-import { getFieldServiceRate, updateFieldServiceRate } from '../service'
-import { toast } from 'sonner'
-
-interface FieldServiceRateProps {
- contractId: number
- contractType?: string
-}
-
-interface FieldServiceRateItem {
- id: string
- levelOfSpecialist: string
- description: string
- rateCurrency: string
- onshoreRate: string
- offshoreRate: string
- rateUnit: string
- remark: string
-}
-
-export function FieldServiceRate({ contractId }: FieldServiceRateProps) {
- const session = useSession()
- const [isLoading, setIsLoading] = useState(false)
- const [isEnabled, setIsEnabled] = useState(true)
-
- // 특정 계약종류를 제외한 일반계약은 Default로 표시
- const isDisabled = false
-
- const [fieldServiceRates, setFieldServiceRates] = useState<FieldServiceRateItem[]>([])
-
- // 초기 데이터 로드
- useEffect(() => {
- const loadFieldServiceRate = async () => {
- try {
- const data = await getFieldServiceRate(contractId)
- if (data && data.enabled !== undefined) {
- setIsEnabled(data.enabled)
- setFieldServiceRates(data.fieldServiceRates || [])
- } else {
- }
- } catch (error) {
- console.error('Field Service Rate 데이터 로드 실패:', error)
- toast.error('Field Service Rate 데이터를 불러오는데 실패했습니다.')
- }
- }
-
- loadFieldServiceRate()
- }, [contractId])
-
- const addFieldServiceRateRow = () => {
- const newRow: FieldServiceRateItem = {
- id: Date.now().toString(),
- levelOfSpecialist: '',
- description: '',
- rateCurrency: 'USD',
- onshoreRate: '',
- offshoreRate: '',
- rateUnit: 'day',
- remark: ''
- }
- setFieldServiceRates([...fieldServiceRates, newRow])
- }
-
- const removeFieldServiceRateRow = (id: string) => {
- setFieldServiceRates(fieldServiceRates.filter(item => item.id !== id))
- }
-
- const updateFieldServiceRateData = (id: string, field: keyof FieldServiceRateItem, value: string) => {
- setFieldServiceRates(prev =>
- prev.map(item =>
- item.id === id ? { ...item, [field]: value } : item
- )
- )
- }
-
- const handleSaveFieldServiceRate = async () => {
- if (!session.data?.user?.id) {
- toast.error('로그인이 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- const fieldServiceRateData = {
- enabled: isEnabled,
- fieldServiceRates: fieldServiceRates
- }
-
- await updateFieldServiceRate(contractId, fieldServiceRateData, Number(session.data.user.id))
- toast.success('Field Service Rate가 성공적으로 저장되었습니다.')
- } catch (error) {
- console.error('Field Service Rate 저장 실패:', error)
- toast.error('Field Service Rate 저장에 실패했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- return (
- <div className="w-full">
- <Accordion type="single" collapsible className="w-full">
- <AccordionItem value="field-service-rate">
- <AccordionTrigger className="hover:no-underline">
- <div className="flex items-center gap-3 w-full">
- <DollarSign className="w-5 h-5" />
- <span className="font-medium">Field Service Rate</span>
- </div>
- </AccordionTrigger>
- <AccordionContent>
- <div className="space-y-6">
- {/* 체크박스 */}
- <div className="flex items-center gap-2">
- <Checkbox
- checked={isEnabled}
- disabled={isDisabled}
- onCheckedChange={(checked) => {
- if (!isDisabled) {
- setIsEnabled(checked as boolean)
- }
- }}
- />
- <span className="text-sm font-medium">Field Service Rate 활성화</span>
- </div>
-
- {/* Field Service Rate 테이블 */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-lg font-medium">Field Service Rate</h3>
- <div className="flex gap-2">
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={addFieldServiceRateRow}
- disabled={isDisabled || !isEnabled}
- className="flex items-center gap-2"
- >
- <Plus className="w-4 h-4" />
- 행 추가
- </Button>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => {
- if (confirm('선택된 행들을 삭제하시겠습니까?')) {
- // 선택된 행들 삭제 로직 (필요시 구현)
- }
- }}
- disabled={isDisabled || !isEnabled}
- className="flex items-center gap-2"
- >
- <Trash2 className="w-4 h-4" />
- 행 삭제
- </Button>
- </div>
- </div>
-
- <div className="overflow-x-auto">
- <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
- <thead>
- <tr className="bg-yellow-100">
- <th className="border border-gray-300 p-2 w-16">No.</th>
- <th className="border border-gray-300 p-2 w-40">Level of Specialist</th>
- <th className="border border-gray-300 p-2">Description</th>
- <th className="border border-gray-300 p-2 w-32">Rate Currency</th>
- <th className="border border-gray-300 p-2 w-32">Onshore Rate</th>
- <th className="border border-gray-300 p-2 w-32">Offshore Rate</th>
- <th className="border border-gray-300 p-2 w-24">Rate Unit</th>
- <th className="border border-gray-300 p-2 w-32">Remark</th>
- <th className="border border-gray-300 p-2 w-20">Action</th>
- </tr>
- </thead>
- <tbody>
- {fieldServiceRates.map((item, index) => (
- <tr key={item.id} className="bg-yellow-50">
- <td className="border border-gray-300 p-2 text-center">{index + 1}</td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.levelOfSpecialist}
- onChange={(e) => updateFieldServiceRateData(item.id, 'levelOfSpecialist', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.description}
- onChange={(e) => updateFieldServiceRateData(item.id, 'description', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.rateCurrency}
- onChange={(e) => updateFieldServiceRateData(item.id, 'rateCurrency', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.onshoreRate}
- onChange={(e) => updateFieldServiceRateData(item.id, 'onshoreRate', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.offshoreRate}
- onChange={(e) => updateFieldServiceRateData(item.id, 'offshoreRate', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.rateUnit}
- onChange={(e) => updateFieldServiceRateData(item.id, 'rateUnit', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.remark}
- onChange={(e) => updateFieldServiceRateData(item.id, 'remark', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2 text-center">
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeFieldServiceRateRow(item.id)}
- disabled={isDisabled || !isEnabled}
- className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
-
- {/* Note 정보 */}
- <div className="space-y-2 text-sm text-gray-600">
- <p><strong>Note #1:</strong> Air fare, travelling costs and hours, Visa, training, medical test and any additional applications are included.</p>
- <p><strong>Note #2:</strong> Accommodation, meal and local transportation are included.</p>
- </div>
- </div>
-
- {/* 저장 버튼 */}
- <div className="flex justify-end pt-4 border-t">
- <Button
- onClick={handleSaveFieldServiceRate}
- disabled={isLoading || isDisabled || !isEnabled}
- className="flex items-center gap-2"
- >
- {isLoading ? (
- <LoaderIcon className="w-4 h-4 animate-spin" />
- ) : (
- <Save className="w-4 h-4" />
- )}
- Field Service Rate 저장
- </Button>
- </div>
- </div>
- </AccordionContent>
- </AccordionItem>
- </Accordion>
- </div>
- )
-}
diff --git a/lib/general-contracts/detail/general-contract-info-header.tsx b/lib/general-contracts/detail/general-contract-info-header.tsx
index 9be9840d..675918a2 100644
--- a/lib/general-contracts/detail/general-contract-info-header.tsx
+++ b/lib/general-contracts/detail/general-contract-info-header.tsx
@@ -52,15 +52,14 @@ const typeLabels = {
'AL': '연간운송계약',
'OS': '외주용역계약',
'OW': '도급계약',
- 'IS': '검사계약',
'LO': 'LOI',
'FA': 'FA',
'SC': '납품합의계약',
'OF': '클레임상계계약',
'AW': '사전작업합의',
'AD': '사전납품합의',
- 'AM': '설계계약',
- 'SC_SELL': '폐기물매각계약'
+ 'SG': '임치(물품보관)계약',
+ 'SR': '폐기물매각계약'
}
export function GeneralContractInfoHeader({ contract }: GeneralContractInfoHeaderProps) {
diff --git a/lib/general-contracts/detail/general-contract-items-table.tsx b/lib/general-contracts/detail/general-contract-items-table.tsx
index 1b9a1a06..ed1e5afb 100644
--- a/lib/general-contracts/detail/general-contract-items-table.tsx
+++ b/lib/general-contracts/detail/general-contract-items-table.tsx
@@ -20,15 +20,26 @@ import {
Package,
Plus,
Trash2,
+ FileSpreadsheet,
+ Save,
+ LoaderIcon
} from 'lucide-react'
import { toast } from 'sonner'
import { updateContractItems, getContractItems } from '../service'
-import { Save, LoaderIcon } from 'lucide-react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
+import { ProjectSelector } from '@/components/ProjectSelector'
+import { MaterialGroupSelectorDialogSingle } from '@/components/common/material/material-group-selector-dialog-single'
+import { MaterialSearchItem } from '@/lib/material/material-group-service'
interface ContractItem {
id?: number
+ projectId?: number | null
+ projectName?: string
+ projectCode?: string
itemCode: string
itemInfo: string
+ materialGroupCode?: string
+ materialGroupDescription?: string
specification: string
quantity: number
quantityUnit: string
@@ -49,6 +60,9 @@ interface ContractItemsTableProps {
onTotalAmountChange: (total: number) => void
availableBudget?: number
readOnly?: boolean
+ contractScope?: string // 계약확정범위 (단가/금액/물량)
+ deliveryType?: string // 납기종류 (단일납기/분할납기)
+ contractDeliveryDate?: string // 기본정보의 계약납기일
}
// 통화 목록
@@ -66,12 +80,28 @@ export function ContractItemsTable({
onItemsChange,
onTotalAmountChange,
availableBudget = 0,
- readOnly = false
+ readOnly = false,
+ contractScope = '',
+ deliveryType = '',
+ contractDeliveryDate = ''
}: ContractItemsTableProps) {
+ // 계약확정범위에 따른 필드 활성화/비활성화
+ const isQuantityDisabled = contractScope === '단가' || contractScope === '물량'
+ const isTotalAmountDisabled = contractScope === '단가' || contractScope === '물량'
+ // 단일납기인 경우 납기일 필드 비활성화 및 기본값 설정
+ const isDeliveryDateDisabled = deliveryType === '단일납기'
const [localItems, setLocalItems] = React.useState<ContractItem[]>(items)
const [isSaving, setIsSaving] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(false)
const [isEnabled, setIsEnabled] = React.useState(true)
+ const [showBatchInputDialog, setShowBatchInputDialog] = React.useState(false)
+ const [batchInputData, setBatchInputData] = React.useState({
+ quantity: '',
+ quantityUnit: 'EA',
+ contractDeliveryDate: '',
+ contractCurrency: 'KRW',
+ contractUnitPrice: ''
+ })
// 초기 데이터 로드
React.useEffect(() => {
@@ -79,21 +109,41 @@ export function ContractItemsTable({
try {
setIsLoading(true)
const fetchedItems = await getContractItems(contractId)
- const formattedItems = fetchedItems.map(item => ({
- id: item.id,
- itemCode: item.itemCode || '',
- itemInfo: item.itemInfo || '',
- specification: item.specification || '',
- quantity: Number(item.quantity) || 0,
- 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 || 'KRW',
- isSelected: false
- })) as ContractItem[]
+ const formattedItems = fetchedItems.map(item => {
+ // itemInfo에서 자재그룹 정보 파싱 (형식: "자재그룹코드 / 자재그룹명")
+ let materialGroupCode = ''
+ let materialGroupDescription = ''
+ if (item.itemInfo) {
+ const parts = item.itemInfo.split(' / ')
+ if (parts.length >= 2) {
+ materialGroupCode = parts[0].trim()
+ materialGroupDescription = parts.slice(1).join(' / ').trim()
+ } else if (parts.length === 1) {
+ materialGroupCode = parts[0].trim()
+ }
+ }
+
+ return {
+ id: item.id,
+ projectId: item.projectId || null,
+ projectName: item.projectName || undefined,
+ projectCode: item.projectCode || undefined,
+ itemCode: item.itemCode || '',
+ itemInfo: item.itemInfo || '',
+ materialGroupCode: materialGroupCode || undefined,
+ materialGroupDescription: materialGroupDescription || undefined,
+ specification: item.specification || '',
+ quantity: Number(item.quantity) || 0,
+ 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 || 'KRW',
+ isSelected: false
+ }
+ }) as ContractItem[]
setLocalItems(formattedItems as ContractItem[])
onItemsChange(formattedItems as ContractItem[])
} catch (error) {
@@ -176,8 +226,11 @@ export function ContractItemsTable({
// 행 추가
const addRow = () => {
const newItem: ContractItem = {
+ projectId: null,
itemCode: '',
itemInfo: '',
+ materialGroupCode: '',
+ materialGroupDescription: '',
specification: '',
quantity: 0,
quantityUnit: 'EA', // 기본 수량 단위
@@ -218,6 +271,43 @@ export function ContractItemsTable({
onItemsChange(updatedItems)
}
+ // 일괄입력 적용
+ const applyBatchInput = () => {
+ if (localItems.length === 0) {
+ toast.error('품목이 없습니다. 먼저 품목을 추가해주세요.')
+ return
+ }
+
+ const updatedItems = localItems.map(item => {
+ const updatedItem = { ...item }
+
+ if (batchInputData.quantity) {
+ updatedItem.quantity = parseFloat(batchInputData.quantity) || 0
+ }
+ if (batchInputData.quantityUnit) {
+ updatedItem.quantityUnit = batchInputData.quantityUnit
+ }
+ if (batchInputData.contractDeliveryDate) {
+ updatedItem.contractDeliveryDate = batchInputData.contractDeliveryDate
+ }
+ if (batchInputData.contractCurrency) {
+ updatedItem.contractCurrency = batchInputData.contractCurrency
+ }
+ if (batchInputData.contractUnitPrice) {
+ updatedItem.contractUnitPrice = parseFloat(batchInputData.contractUnitPrice) || 0
+ // 단가가 변경되면 계약금액도 재계산
+ updatedItem.contractAmount = updatedItem.contractUnitPrice * updatedItem.quantity
+ }
+
+ return updatedItem
+ })
+
+ setLocalItems(updatedItems)
+ onItemsChange(updatedItems)
+ setShowBatchInputDialog(false)
+ toast.success('일괄입력이 적용되었습니다.')
+ }
+
// 통화 포맷팅
const formatCurrency = (amount: number, currency: string = 'KRW') => {
@@ -292,6 +382,104 @@ export function ContractItemsTable({
<Plus className="w-4 h-4" />
행 추가
</Button>
+ <Dialog open={showBatchInputDialog} onOpenChange={setShowBatchInputDialog}>
+ <DialogTrigger asChild>
+ <Button
+ variant="outline"
+ size="sm"
+ disabled={!isEnabled || localItems.length === 0}
+ className="flex items-center gap-2"
+ >
+ <FileSpreadsheet className="w-4 h-4" />
+ 일괄입력
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="max-w-md">
+ <DialogHeader>
+ <DialogTitle>품목 정보 일괄입력</DialogTitle>
+ </DialogHeader>
+ <div className="space-y-4 py-4">
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-quantity">수량</Label>
+ <Input
+ id="batch-quantity"
+ type="number"
+ value={batchInputData.quantity}
+ onChange={(e) => setBatchInputData(prev => ({ ...prev, quantity: e.target.value }))}
+ placeholder="수량 입력 (선택사항)"
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-quantity-unit">수량단위</Label>
+ <Select
+ value={batchInputData.quantityUnit}
+ onValueChange={(value) => setBatchInputData(prev => ({ ...prev, quantityUnit: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {QUANTITY_UNITS.map((unit) => (
+ <SelectItem key={unit} value={unit}>
+ {unit}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-delivery-date">계약납기일</Label>
+ <Input
+ id="batch-delivery-date"
+ type="date"
+ value={batchInputData.contractDeliveryDate}
+ onChange={(e) => setBatchInputData(prev => ({ ...prev, contractDeliveryDate: e.target.value }))}
+ />
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-currency">계약통화</Label>
+ <Select
+ value={batchInputData.contractCurrency}
+ onValueChange={(value) => setBatchInputData(prev => ({ ...prev, contractCurrency: value }))}
+ >
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ <SelectContent>
+ {CURRENCIES.map((currency) => (
+ <SelectItem key={currency} value={currency}>
+ {currency}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="batch-unit-price">계약단가</Label>
+ <Input
+ id="batch-unit-price"
+ type="number"
+ value={batchInputData.contractUnitPrice}
+ onChange={(e) => setBatchInputData(prev => ({ ...prev, contractUnitPrice: e.target.value }))}
+ placeholder="계약단가 입력 (선택사항)"
+ />
+ </div>
+ <div className="flex justify-end gap-2 pt-4">
+ <Button
+ variant="outline"
+ onClick={() => setShowBatchInputDialog(false)}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={applyBatchInput}
+ >
+ 적용
+ </Button>
+ </div>
+ </div>
+ </DialogContent>
+ </Dialog>
<Button
variant="outline"
size="sm"
@@ -322,8 +510,8 @@ export function ContractItemsTable({
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4">
<div className="space-y-1">
<Label className="text-sm font-medium">총 계약금액</Label>
- <div className="text-lg font-bold text-primary">
- {formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
+ <div className={`text-lg font-bold ${isTotalAmountDisabled ? 'text-gray-400' : 'text-primary'}`}>
+ {isTotalAmountDisabled ? '-' : formatCurrency(totalAmount, localItems[0]?.contractCurrency || 'KRW')}
</div>
</div>
<div className="space-y-1">
@@ -364,8 +552,10 @@ 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">자재내역(자재그룹명)</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>
@@ -393,6 +583,21 @@ export function ContractItemsTable({
</TableCell>
<TableCell className="px-3 py-3">
{readOnly ? (
+ <span className="text-sm">{item.projectCode && item.projectName ? `${item.projectCode} - ${item.projectName}` : '-'}</span>
+ ) : (
+ <ProjectSelector
+ selectedProjectId={item.projectId || undefined}
+ onProjectSelect={(project) => {
+ updateItem(index, 'projectId', project.id)
+ updateItem(index, 'projectName', project.projectName)
+ updateItem(index, 'projectCode', project.projectCode)
+ }}
+ placeholder="프로젝트 선택"
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
<span className="text-sm">{item.itemCode || '-'}</span>
) : (
<Input
@@ -406,13 +611,42 @@ export function ContractItemsTable({
</TableCell>
<TableCell className="px-3 py-3">
{readOnly ? (
- <span className="text-sm">{item.itemInfo || '-'}</span>
+ <span className="text-sm">{item.materialGroupCode || '-'}</span>
+ ) : (
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel={item.materialGroupCode || "자재그룹 선택"}
+ triggerVariant="outline"
+ selectedMaterial={item.materialGroupCode ? {
+ materialGroupCode: item.materialGroupCode,
+ materialGroupDescription: item.materialGroupDescription || '',
+ displayText: `${item.materialGroupCode} - ${item.materialGroupDescription || ''}`
+ } : null}
+ onMaterialSelect={(material) => {
+ if (material) {
+ updateItem(index, 'materialGroupCode', material.materialGroupCode)
+ updateItem(index, 'materialGroupDescription', material.materialGroupDescription)
+ updateItem(index, 'itemInfo', `${material.materialGroupCode} / ${material.materialGroupDescription}`)
+ } else {
+ updateItem(index, 'materialGroupCode', '')
+ updateItem(index, 'materialGroupDescription', '')
+ updateItem(index, 'itemInfo', '')
+ }
+ }}
+ title="자재그룹 선택"
+ description="자재그룹을 검색하고 선택해주세요."
+ />
+ )}
+ </TableCell>
+ <TableCell className="px-3 py-3">
+ {readOnly ? (
+ <span className="text-sm">{item.materialGroupDescription || item.itemInfo || '-'}</span>
) : (
<Input
- value={item.itemInfo}
- onChange={(e) => updateItem(index, 'itemInfo', e.target.value)}
- placeholder="Item 정보"
- className="h-8 text-sm"
+ value={item.materialGroupDescription || item.itemInfo || ''}
+ onChange={(e) => updateItem(index, 'materialGroupDescription', e.target.value)}
+ placeholder="자재그룹명"
+ className="h-8 text-sm bg-muted/50"
+ readOnly
disabled={!isEnabled}
/>
)}
@@ -440,7 +674,7 @@ export function ContractItemsTable({
onChange={(e) => updateItem(index, 'quantity', parseFloat(e.target.value) || 0)}
className="h-8 text-sm text-right"
placeholder="0"
- disabled={!isEnabled}
+ disabled={!isEnabled || isQuantityDisabled}
/>
)}
</TableCell>
@@ -451,7 +685,7 @@ export function ContractItemsTable({
<Select
value={item.quantityUnit}
onValueChange={(value) => updateItem(index, 'quantityUnit', value)}
- disabled={!isEnabled}
+ disabled={!isEnabled || isQuantityDisabled}
>
<SelectTrigger className="h-8 text-sm w-20">
<SelectValue />
@@ -476,7 +710,7 @@ export function ContractItemsTable({
onChange={(e) => updateItem(index, 'totalWeight', parseFloat(e.target.value) || 0)}
className="h-8 text-sm text-right"
placeholder="0"
- disabled={!isEnabled}
+ disabled={!isEnabled || isQuantityDisabled}
/>
)}
</TableCell>
@@ -511,7 +745,7 @@ export function ContractItemsTable({
value={item.contractDeliveryDate}
onChange={(e) => updateItem(index, 'contractDeliveryDate', e.target.value)}
className="h-8 text-sm"
- disabled={!isEnabled}
+ disabled={!isEnabled || isDeliveryDateDisabled}
/>
)}
</TableCell>
diff --git a/lib/general-contracts/detail/general-contract-location.tsx b/lib/general-contracts/detail/general-contract-location.tsx
deleted file mode 100644
index 5b388895..00000000
--- a/lib/general-contracts/detail/general-contract-location.tsx
+++ /dev/null
@@ -1,480 +0,0 @@
-'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Save, LoaderIcon, MapPin } from 'lucide-react'
-import { updateLocation, getLocation } from '../service'
-import { toast } from 'sonner'
-
-interface LocationProps {
- contractType?: string
- contractId: number
-}
-
-interface LocationData {
- country: {
- projectManager: string
- engineering: string
- procurement: string
- fabrication: string
- assembly: string
- test: string
- shippingExw: string
- shippingFob: string
- remark: string
- }
- location: {
- projectManager: string
- engineering: string
- procurement: string
- fabrication: string
- assembly: string
- test: string
- shippingExw: string
- shippingFob: string
- remark: string
- }
- subContractor: {
- projectManager: string
- engineering: string
- procurement: string
- fabrication: string
- assembly: string
- test: string
- shippingExw: string
- shippingFob: string
- remark: string
- }
-}
-
-export function Location({ contractId }: LocationProps) {
- const session = useSession()
- const [isLoading, setIsLoading] = useState(false)
- const [isEnabled, setIsEnabled] = useState(true)
- const [locationData, setLocationData] = useState<LocationData>({
- country: {
- projectManager: '',
- engineering: '',
- procurement: '',
- fabrication: '',
- assembly: '',
- test: '',
- shippingExw: '',
- shippingFob: '',
- remark: ''
- },
- location: {
- projectManager: '',
- engineering: '',
- procurement: '',
- fabrication: '',
- assembly: '',
- test: '',
- shippingExw: '',
- shippingFob: '',
- remark: ''
- },
- subContractor: {
- projectManager: '',
- engineering: '',
- procurement: '',
- fabrication: '',
- assembly: '',
- test: '',
- shippingExw: '',
- shippingFob: '',
- remark: ''
- }
- })
-
- // 특정 계약종류를 제외한 일반계약은 Default로 표시
- const isDisabled = false // 일단 모든 계약종류에서 활성화
-
- // 초기 데이터 로드
- useEffect(() => {
- const loadLocationData = async () => {
- try {
- const data = await getLocation(contractId)
- if (data && data.locations) {
- setLocationData(data.locations)
- setIsEnabled(data.enabled || true)
- } else {
- // 기본 데이터는 이미 useState에서 설정됨
- }
- } catch (error) {
- console.error('Error loading location data:', error)
- // 기본 데이터는 이미 useState에서 설정됨
- }
- }
-
- loadLocationData()
- }, [contractId])
-
- const updateLocationData = (rowType: keyof LocationData, field: keyof LocationData['country'], value: string) => {
- setLocationData(prev => ({
- ...prev,
- [rowType]: {
- ...prev[rowType],
- [field]: value
- }
- }))
- }
-
- const handleSaveLocation = async () => {
- const userId = session.data?.user?.id ? Number(session.data.user.id) : null
-
- if (!userId) {
- toast.error('사용자 정보를 찾을 수 없습니다.')
- return
- }
-
- try {
- setIsLoading(true)
-
- const locationDataToSave = {
- enabled: isEnabled,
- locations: locationData
- }
-
- await updateLocation(contractId, locationDataToSave, userId)
- toast.success('Location 정보가 저장되었습니다.')
- } catch (error) {
- console.error('Error saving location:', error)
- toast.error('Location 정보 저장에 실패했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- return (
- <div className="w-full">
- <Accordion type="single" collapsible className="w-full">
- <AccordionItem value="location">
- <AccordionTrigger className="hover:no-underline">
- <div className="flex items-center gap-3 w-full">
- <MapPin className="w-5 h-5" />
- <span className="font-medium">Location</span>
- </div>
- </AccordionTrigger>
- <AccordionContent>
- <div className="space-y-6">
- {/* 체크박스 */}
- <div className="flex items-center gap-2">
- <Checkbox
- checked={isEnabled}
- disabled={isDisabled}
- onCheckedChange={(checked) => {
- if (!isDisabled) {
- setIsEnabled(checked as boolean)
- }
- }}
- />
- <span className="text-sm font-medium">Location 활성화</span>
- </div>
-
- {/* Location 테이블 */}
- <div className="space-y-4">
- <h3 className="text-lg font-medium">Location</h3>
-
- <div className="overflow-x-auto">
- <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
- <thead>
- <tr className="bg-yellow-100">
- <th className="border border-gray-300 p-2">Activity</th>
- <th className="border border-gray-300 p-2">Project Manager</th>
- <th className="border border-gray-300 p-2">Engineering</th>
- <th className="border border-gray-300 p-2">Procurement</th>
- <th className="border border-gray-300 p-2">Fabrication</th>
- <th className="border border-gray-300 p-2">Assembly</th>
- <th className="border border-gray-300 p-2">Test (FAT)</th>
- <th className="border border-gray-300 p-2">Shipping (EXW)</th>
- <th className="border border-gray-300 p-2">Shipping (FOB)</th>
- <th className="border border-gray-300 p-2">Remark</th>
- </tr>
- </thead>
- <tbody>
- {/* Country Row */}
- <tr className="bg-yellow-50">
- <td className="border border-gray-300 p-2 font-medium">Country</td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.country?.projectManager || ''}
- onChange={(e) => updateLocationData('country', 'projectManager', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.country?.engineering || ''}
- onChange={(e) => updateLocationData('country', 'engineering', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.country.procurement}
- onChange={(e) => updateLocationData('country', 'procurement', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.country.fabrication}
- onChange={(e) => updateLocationData('country', 'fabrication', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.country.assembly}
- onChange={(e) => updateLocationData('country', 'assembly', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.country.test}
- onChange={(e) => updateLocationData('country', 'test', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.country.shippingExw}
- onChange={(e) => updateLocationData('country', 'shippingExw', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.country.shippingFob}
- onChange={(e) => updateLocationData('country', 'shippingFob', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.country.remark}
- onChange={(e) => updateLocationData('country', 'remark', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- </tr>
-
- {/* Location (City) Row */}
- <tr className="bg-yellow-50">
- <td className="border border-gray-300 p-2 font-medium">Location (City)</td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.location.projectManager}
- onChange={(e) => updateLocationData('location', 'projectManager', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 내 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.location.engineering}
- onChange={(e) => updateLocationData('location', 'engineering', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 내 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.location.procurement}
- onChange={(e) => updateLocationData('location', 'procurement', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 내 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.location.fabrication}
- onChange={(e) => updateLocationData('location', 'fabrication', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 내 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.location.assembly}
- onChange={(e) => updateLocationData('location', 'assembly', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 내 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.location.test}
- onChange={(e) => updateLocationData('location', 'test', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 내 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.location.shippingExw}
- onChange={(e) => updateLocationData('location', 'shippingExw', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 내 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.location.shippingFob}
- onChange={(e) => updateLocationData('location', 'shippingFob', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 내 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.location.remark}
- onChange={(e) => updateLocationData('location', 'remark', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- </tr>
-
- {/* Sub-Contractor Row */}
- <tr className="bg-yellow-50">
- <td className="border border-gray-300 p-2 font-medium">Sub-Contractor<br />(where applicable)</td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.subContractor?.projectManager || ''}
- onChange={(e) => updateLocationData('subContractor', 'projectManager', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 및 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.subContractor?.engineering || ''}
- onChange={(e) => updateLocationData('subContractor', 'engineering', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 및 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.subContractor?.procurement || ''}
- onChange={(e) => updateLocationData('subContractor', 'procurement', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 및 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.subContractor?.fabrication || ''}
- onChange={(e) => updateLocationData('subContractor', 'fabrication', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 및 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.subContractor?.assembly || ''}
- onChange={(e) => updateLocationData('subContractor', 'assembly', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 및 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.subContractor?.test || ''}
- onChange={(e) => updateLocationData('subContractor', 'test', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 및 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.subContractor?.shippingExw || ''}
- onChange={(e) => updateLocationData('subContractor', 'shippingExw', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 및 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.subContractor?.shippingFob || ''}
- onChange={(e) => updateLocationData('subContractor', 'shippingFob', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- placeholder="국가 및 지역"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={locationData?.subContractor?.remark || ''}
- onChange={(e) => updateLocationData('subContractor', 'remark', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- </div>
-
- {/* 저장 버튼 */}
- <div className="flex justify-end pt-4 border-t">
- <Button
- onClick={handleSaveLocation}
- disabled={isLoading || isDisabled || !isEnabled}
- className="flex items-center gap-2"
- >
- {isLoading ? (
- <LoaderIcon className="w-4 h-4 animate-spin" />
- ) : (
- <Save className="w-4 h-4" />
- )}
- Location 저장
- </Button>
- </div>
- </div>
- </AccordionContent>
- </AccordionItem>
- </Accordion>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/general-contracts/detail/general-contract-offset-details.tsx b/lib/general-contracts/detail/general-contract-offset-details.tsx
deleted file mode 100644
index af4f2ef2..00000000
--- a/lib/general-contracts/detail/general-contract-offset-details.tsx
+++ /dev/null
@@ -1,314 +0,0 @@
-'use client'
-
-import React, { useState, useEffect } from 'react'
-import { useSession } from 'next-auth/react'
-import { Input } from '@/components/ui/input'
-import { Button } from '@/components/ui/button'
-import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
-import { Checkbox } from '@/components/ui/checkbox'
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
-import { Plus, Trash2, Save, LoaderIcon, RotateCcw } from 'lucide-react'
-import { getOffsetDetails, updateOffsetDetails } from '../service'
-import { toast } from 'sonner'
-
-interface OffsetDetailsProps {
- contractId: number
- contractType?: string
-}
-
-interface OffsetDetailItem {
- id: string
- project: string
- poNumber: string
- poItemDescription: string
- offsetReason: string
- contractCurrency: string
- contractAmount: string
- offsetDate: string
- remark: string
-}
-
-export function OffsetDetails({ contractId }: OffsetDetailsProps) {
- const session = useSession()
- const [isLoading, setIsLoading] = useState(false)
- const [isEnabled, setIsEnabled] = useState(true)
-
- // 특정 계약종류를 제외한 일반계약은 Default로 표시
- const isDisabled = false
-
- const [offsetDetails, setOffsetDetails] = useState<OffsetDetailItem[]>([])
-
- // 회입/상계사유 옵션
- const offsetReasonOptions = [
- '판매자 사양불만족',
- '납기지연',
- '품질불량',
- '계약조건변경',
- '기타'
- ]
-
- // 초기 데이터 로드
- useEffect(() => {
- const loadOffsetDetails = async () => {
- try {
- const data = await getOffsetDetails(contractId)
- if (data && data.enabled !== undefined) {
- setIsEnabled(data.enabled)
- setOffsetDetails(data.offsetDetails || [])
- } else {
- }
- } catch (error) {
- console.error('회입/상계내역 데이터 로드 실패:', error)
- toast.error('회입/상계내역 데이터를 불러오는데 실패했습니다.')
- }
- }
-
- loadOffsetDetails()
- }, [contractId])
-
- const addOffsetDetailRow = () => {
- const newRow: OffsetDetailItem = {
- id: Date.now().toString(),
- project: '',
- poNumber: '',
- poItemDescription: '',
- offsetReason: '',
- contractCurrency: 'KRW',
- contractAmount: '',
- offsetDate: '',
- remark: ''
- }
- setOffsetDetails([...offsetDetails, newRow])
- }
-
- const removeOffsetDetailRow = (id: string) => {
- setOffsetDetails(offsetDetails.filter(item => item.id !== id))
- }
-
- const updateOffsetDetailData = (id: string, field: keyof OffsetDetailItem, value: string) => {
- setOffsetDetails(prev =>
- prev.map(item =>
- item.id === id ? { ...item, [field]: value } : item
- )
- )
- }
-
- const handleSaveOffsetDetails = async () => {
- if (!session.data?.user?.id) {
- toast.error('로그인이 필요합니다.')
- return
- }
-
- setIsLoading(true)
- try {
- const offsetDetailsData = {
- enabled: isEnabled,
- offsetDetails: offsetDetails
- }
-
- await updateOffsetDetails(contractId, offsetDetailsData, Number(session.data.user.id))
- toast.success('회입/상계내역이 성공적으로 저장되었습니다.')
- } catch (error) {
- console.error('회입/상계내역 저장 실패:', error)
- toast.error('회입/상계내역 저장에 실패했습니다.')
- } finally {
- setIsLoading(false)
- }
- }
-
- return (
- <div className="w-full">
- <Accordion type="single" collapsible className="w-full">
- <AccordionItem value="offset-details">
- <AccordionTrigger className="hover:no-underline">
- <div className="flex items-center gap-3 w-full">
- <RotateCcw className="w-5 h-5" />
- <span className="font-medium">회입/상계내역</span>
- </div>
- </AccordionTrigger>
- <AccordionContent>
- <div className="space-y-6">
- {/* 체크박스 */}
- <div className="flex items-center gap-2">
- <Checkbox
- checked={isEnabled}
- disabled={isDisabled}
- onCheckedChange={(checked) => {
- if (!isDisabled) {
- setIsEnabled(checked as boolean)
- }
- }}
- />
- <span className="text-sm font-medium">회입/상계내역 활성화</span>
- </div>
-
- {/* 회입/상계내역 테이블 */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <h3 className="text-lg font-medium">회입/상계내역</h3>
- <div className="flex gap-2">
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={addOffsetDetailRow}
- disabled={isDisabled || !isEnabled}
- className="flex items-center gap-2"
- >
- <Plus className="w-4 h-4" />
- 행 추가
- </Button>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={() => {
- if (confirm('선택된 행들을 삭제하시겠습니까?')) {
- // 선택된 행들 삭제 로직 (필요시 구현)
- }
- }}
- disabled={isDisabled || !isEnabled}
- className="flex items-center gap-2"
- >
- <Trash2 className="w-4 h-4" />
- 행 삭제
- </Button>
- </div>
- </div>
-
- <div className="overflow-x-auto">
- <table className={`w-full border-collapse border border-gray-300 ${!isEnabled ? 'opacity-50' : ''}`}>
- <thead>
- <tr className="bg-yellow-100">
- <th className="border border-gray-300 p-2 w-16">No.</th>
- <th className="border border-gray-300 p-2 w-32">프로젝트</th>
- <th className="border border-gray-300 p-2 w-40">발주번호</th>
- <th className="border border-gray-300 p-2">발주품목(Description)</th>
- <th className="border border-gray-300 p-2 w-40">회입/상계사유</th>
- <th className="border border-gray-300 p-2 w-32">계약통화</th>
- <th className="border border-gray-300 p-2 w-32">계약금액</th>
- <th className="border border-gray-300 p-2 w-32">회입/상계일</th>
- <th className="border border-gray-300 p-2">비고</th>
- <th className="border border-gray-300 p-2 w-20">Action</th>
- </tr>
- </thead>
- <tbody>
- {offsetDetails.map((item, index) => (
- <tr key={item.id} className="bg-yellow-50">
- <td className="border border-gray-300 p-2 text-center">{index + 1}</td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.project}
- onChange={(e) => updateOffsetDetailData(item.id, 'project', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.poNumber}
- onChange={(e) => updateOffsetDetailData(item.id, 'poNumber', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.poItemDescription}
- onChange={(e) => updateOffsetDetailData(item.id, 'poItemDescription', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Select
- value={item.offsetReason}
- onValueChange={(value) => updateOffsetDetailData(item.id, 'offsetReason', value)}
- disabled={isDisabled || !isEnabled}
- >
- <SelectTrigger className="border-0 bg-transparent p-0 h-auto">
- <SelectValue placeholder="선택하세요" />
- </SelectTrigger>
- <SelectContent>
- {offsetReasonOptions.map((option) => (
- <SelectItem key={option} value={option}>
- {option}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.contractCurrency}
- onChange={(e) => updateOffsetDetailData(item.id, 'contractCurrency', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.contractAmount}
- onChange={(e) => updateOffsetDetailData(item.id, 'contractAmount', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- type="date"
- value={item.offsetDate}
- onChange={(e) => updateOffsetDetailData(item.id, 'offsetDate', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2">
- <Input
- value={item.remark}
- onChange={(e) => updateOffsetDetailData(item.id, 'remark', e.target.value)}
- disabled={isDisabled || !isEnabled}
- className="border-0 bg-transparent p-0 h-auto"
- />
- </td>
- <td className="border border-gray-300 p-2 text-center">
- <Button
- type="button"
- variant="ghost"
- size="sm"
- onClick={() => removeOffsetDetailRow(item.id)}
- disabled={isDisabled || !isEnabled}
- className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
- >
- <Trash2 className="w-4 h-4" />
- </Button>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
- </div>
-
- {/* 저장 버튼 */}
- <div className="flex justify-end pt-4 border-t">
- <Button
- onClick={handleSaveOffsetDetails}
- disabled={isLoading || isDisabled || !isEnabled}
- className="flex items-center gap-2"
- >
- {isLoading ? (
- <LoaderIcon className="w-4 h-4 animate-spin" />
- ) : (
- <Save className="w-4 h-4" />
- )}
- 회입/상계내역 저장
- </Button>
- </div>
- </div>
- </AccordionContent>
- </AccordionItem>
- </Accordion>
- </div>
- )
-}
diff --git a/lib/general-contracts/detail/general-contract-review-comments.tsx b/lib/general-contracts/detail/general-contract-review-comments.tsx
new file mode 100644
index 00000000..e80211f2
--- /dev/null
+++ b/lib/general-contracts/detail/general-contract-review-comments.tsx
@@ -0,0 +1,194 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Label } from '@/components/ui/label'
+import { Textarea } from '@/components/ui/textarea'
+import { Button } from '@/components/ui/button'
+import { MessageSquare, Send, Save } from 'lucide-react'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+import {
+ getContractReviewComments,
+ confirmContractReview
+} from '../service'
+
+interface ContractReviewCommentsProps {
+ contractId: number
+ contractStatus: string
+}
+
+export function ContractReviewComments({ contractId, contractStatus }: ContractReviewCommentsProps) {
+ const session = useSession()
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+
+ const [vendorComment, setVendorComment] = useState<string>('')
+ const [shiComment, setShiComment] = useState<string>('')
+ const [isSaving, setIsSaving] = useState(false)
+ const [isEditingShiComment, setIsEditingShiComment] = useState(false)
+
+ // 계약 상태에 따른 표시 여부
+ const showVendorComment = ['Request to Review', 'Vendor Replied Review', 'SHI Confirmed Review'].includes(contractStatus)
+ const showShiComment = ['Vendor Replied Review', 'SHI Confirmed Review'].includes(contractStatus)
+ const canEditShiComment = contractStatus === 'Vendor Replied Review' && userId
+
+ useEffect(() => {
+ const loadComments = async () => {
+ try {
+ const result = await getContractReviewComments(contractId)
+ if (result.success) {
+ if (result.vendorComment) {
+ setVendorComment(result.vendorComment)
+ }
+ if (result.shiComment) {
+ setShiComment(result.shiComment)
+ setIsEditingShiComment(false) // 이미 저장된 의견이 있으면 편집 모드 해제
+ } else {
+ setIsEditingShiComment(canEditShiComment ? true : false) // 의견이 없고 편집 가능하면 편집 모드
+ }
+ }
+ } catch (error) {
+ console.error('의견 로드 오류:', error)
+ }
+ }
+
+ if (showVendorComment || showShiComment) {
+ loadComments()
+ }
+ }, [contractId, showVendorComment, showShiComment, canEditShiComment])
+
+ const handleConfirmReview = async () => {
+ if (!shiComment.trim()) {
+ toast.error('SHI 의견을 입력해주세요.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ await confirmContractReview(contractId, shiComment, userId)
+ toast.success('검토가 확정되었습니다.')
+ // 페이지 새로고침
+ window.location.reload()
+ } catch (error) {
+ console.error('검토 확정 오류:', error)
+ const errorMessage = error instanceof Error ? error.message : '검토 확정에 실패했습니다.'
+ toast.error(errorMessage)
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5" />
+ 계약 조건 검토 의견
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-6">
+ {/* Vendor Comment */}
+ {showVendorComment && (
+ <div className="space-y-2">
+ <Label className="text-sm font-medium">Vendor Comment</Label>
+ <div className="min-h-[120px] p-4 bg-yellow-50 border-2 border-yellow-200 rounded-lg">
+ {vendorComment ? (
+ <p className="text-sm whitespace-pre-wrap">{vendorComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">협력업체 의견이 없습니다.</p>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* SHI Comment */}
+ {showShiComment && (
+ <div className="space-y-2">
+ <Label className="text-sm font-medium">SHI Comment</Label>
+ {isEditingShiComment ? (
+ <div className="space-y-2">
+ <Textarea
+ value={shiComment}
+ onChange={(e) => setShiComment(e.target.value)}
+ placeholder="SHI 의견을 입력하세요"
+ rows={6}
+ className="resize-none"
+ disabled={isSaving}
+ />
+ <div className="flex gap-2">
+ <Button
+ onClick={handleConfirmReview}
+ disabled={isSaving || !shiComment.trim()}
+ className="flex-1"
+ >
+ {isSaving ? (
+ <>
+ <Save className="h-4 w-4 mr-2 animate-spin" />
+ 확정 중...
+ </>
+ ) : (
+ <>
+ <Send className="h-4 w-4 mr-2" />
+ 의견 회신 및 검토 확정
+ </>
+ )}
+ </Button>
+ {shiComment && (
+ <Button
+ variant="outline"
+ onClick={() => {
+ setIsEditingShiComment(false)
+ // 원래 값으로 복원하기 위해 다시 로드
+ getContractReviewComments(contractId).then((result) => {
+ if (result.success) {
+ setShiComment(result.shiComment || '')
+ }
+ })
+ }}
+ disabled={isSaving}
+ >
+ 취소
+ </Button>
+ )}
+ </div>
+ </div>
+ ) : (
+ <div className="space-y-2">
+ <div className="min-h-[120px] p-4 bg-gray-50 border rounded-lg">
+ {shiComment ? (
+ <p className="text-sm whitespace-pre-wrap">{shiComment}</p>
+ ) : (
+ <p className="text-sm text-muted-foreground">SHI 의견이 없습니다.</p>
+ )}
+ </div>
+ {canEditShiComment && (
+ <Button
+ variant="outline"
+ onClick={() => setIsEditingShiComment(true)}
+ className="w-full"
+ >
+ <Save className="h-4 w-4 mr-2" />
+ 의견 수정
+ </Button>
+ )}
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* 상태가 아닌 경우 안내 메시지 */}
+ {!showVendorComment && !showShiComment && (
+ <div className="text-center py-8 text-muted-foreground">
+ <p>조건검토 요청 상태가 아닙니다.</p>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/lib/general-contracts/detail/general-contract-review-request-dialog.tsx b/lib/general-contracts/detail/general-contract-review-request-dialog.tsx
new file mode 100644
index 00000000..b487ae25
--- /dev/null
+++ b/lib/general-contracts/detail/general-contract-review-request-dialog.tsx
@@ -0,0 +1,891 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { useSession } from 'next-auth/react'
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Checkbox } from '@/components/ui/checkbox'
+import { Label } from '@/components/ui/label'
+import { Input } from '@/components/ui/input'
+import { toast } from 'sonner'
+import {
+ FileText,
+ Upload,
+ Eye,
+ Send,
+ CheckCircle,
+ Download,
+} from 'lucide-react'
+import {
+ getBasicInfo,
+ getContractItems,
+ getSubcontractChecklist,
+ uploadContractReviewFile,
+ sendContractReviewRequest,
+ getContractById
+} from '../service'
+
+interface ContractReviewRequestDialogProps {
+ contract: Record<string, unknown>
+ open: boolean
+ onOpenChange: (open: boolean) => void
+}
+
+interface ContractSummary {
+ basicInfo: Record<string, unknown>
+ items: Record<string, unknown>[]
+ subcontractChecklist: Record<string, unknown> | null
+}
+
+export function ContractReviewRequestDialog({
+ contract,
+ open,
+ onOpenChange
+}: ContractReviewRequestDialogProps) {
+ const { data: session } = useSession()
+ const [currentStep, setCurrentStep] = useState(1)
+ const [contractSummary, setContractSummary] = useState<ContractSummary | null>(null)
+ const [uploadedFile, setUploadedFile] = useState<File | null>(null)
+ const [generatedPdfUrl, setGeneratedPdfUrl] = useState<string | null>(null)
+ const [generatedPdfBuffer, setGeneratedPdfBuffer] = useState<Uint8Array | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+ const [pdfViewerInstance, setPdfViewerInstance] = useState<unknown>(null)
+ const [isPdfPreviewVisible, setIsPdfPreviewVisible] = useState(false)
+
+ const contractId = contract.id as number
+ const userId = session?.user?.id || ''
+
+ // LOI 템플릿용 변수 매핑 함수
+ const mapContractSummaryToLOITemplate = (contractSummary: ContractSummary) => {
+ const { basicInfo, items } = contractSummary
+ const firstItem = items && items.length > 0 ? items[0] : {}
+
+ // 날짜 포맷팅 헬퍼 함수
+ const formatDate = (date: unknown) => {
+ if (!date) return ''
+ try {
+ const d = new Date(date)
+ return d.toLocaleDateString('ko-KR')
+ } catch {
+ return ''
+ }
+ }
+
+ return {
+ // 날짜 관련 (템플릿에서 {{todayDate}} 형식으로 사용)
+ todayDate: new Date().toLocaleDateString('ko-KR'),
+
+ // 벤더 정보
+ vendorName: basicInfo?.vendorName || '',
+ representativeName: '', // 벤더 대표자 이름 - 현재 데이터에 없음, 향후 확장 가능
+
+ // 계약 기본 정보
+ contractNumber: basicInfo?.contractNumber || '',
+
+ // 프로젝트 정보
+ projectNumber: '', // 프로젝트 코드 - 현재 데이터에 없음, 향후 확장 가능
+ projectName: basicInfo?.projectName || '',
+ project: basicInfo?.projectName || '',
+
+ // 아이템 정보
+ item: firstItem?.itemInfo || '',
+
+ // 무역 조건
+ incoterms: basicInfo?.deliveryTerm || '', // Incoterms 대신 deliveryTerm 사용
+ shippingLocation: basicInfo?.shippingLocation || '',
+
+ // 금액 및 통화
+ contractCurrency: basicInfo?.currency || '',
+ contractAmount: basicInfo?.contractAmount || '',
+ totalAmount: basicInfo?.contractAmount || '', // totalAmount가 없으면 contractAmount 사용
+
+ // 수량
+ quantity: firstItem?.quantity || '',
+
+ // 납기일
+ contractDeliveryDate: formatDate(basicInfo?.contractDeliveryDate),
+
+ // 지급 조건
+ paymentTerm: basicInfo?.paymentTerm || '',
+
+ // 유효기간
+ validityEndDate: formatDate(basicInfo?.endDate), // validityEndDate 대신 endDate 사용
+ }
+ }
+
+ // 1단계: 계약 현황 수집
+ const collectContractSummary = React.useCallback(async () => {
+ setIsLoading(true)
+ try {
+ // 각 컴포넌트에서 활성화된 데이터만 수집
+ const summary: ContractSummary = {
+ basicInfo: {},
+ items: [],
+ subcontractChecklist: null
+ }
+
+ // Basic Info 확인 (항상 활성화)
+ try {
+ const basicInfoData = await getBasicInfo(contractId)
+ if (basicInfoData && basicInfoData.success) {
+ summary.basicInfo = basicInfoData.data || {}
+ }
+ // externalYardEntry 정보도 추가로 가져오기
+ const contractData = await getContractById(contractId)
+ if (contractData) {
+ summary.basicInfo = {
+ ...summary.basicInfo,
+ externalYardEntry: contractData.externalYardEntry || 'N'
+ }
+ }
+ } catch {
+ console.log('Basic Info 데이터 없음')
+ }
+
+ // 품목 정보 확인
+ try {
+ const itemsData = await getContractItems(contractId)
+ if (itemsData && itemsData.length > 0) {
+ summary.items = itemsData
+ }
+ } catch {
+ console.log('품목 정보 데이터 없음')
+ }
+
+ try {
+ // Subcontract Checklist 확인
+ const subcontractData = await getSubcontractChecklist(contractId)
+ if (subcontractData && subcontractData.success && subcontractData.enabled) {
+ summary.subcontractChecklist = subcontractData.data
+ }
+ } catch {
+ console.log('Subcontract Checklist 데이터 없음')
+ }
+
+ console.log('contractSummary 구조:', summary)
+ console.log('basicInfo 내용:', summary.basicInfo)
+ setContractSummary(summary)
+ } catch (error) {
+ console.error('Error collecting contract summary:', error)
+ toast.error('계약 정보를 수집하는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [contractId])
+
+ // 3단계: 파일 업로드 처리
+ const handleFileUpload = async (file: File) => {
+ // 파일 확장자 검증
+ const allowedExtensions = ['.doc', '.docx']
+ const fileExtension = file.name.toLowerCase().substring(file.name.lastIndexOf('.'))
+
+ if (!allowedExtensions.includes(fileExtension)) {
+ toast.error('Word 문서(.doc, .docx) 파일만 업로드 가능합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 서버액션을 사용하여 파일 저장 (조건검토용)
+ const result = await uploadContractReviewFile(
+ contractId,
+ file,
+ userId
+ )
+
+ if (result.success) {
+ setUploadedFile(file)
+ toast.success('파일이 업로드되었습니다.')
+ } else {
+ throw new Error(result.error || '파일 업로드 실패')
+ }
+ } catch (error) {
+ console.error('Error uploading file:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 4단계: PDF 생성 및 미리보기 (PDFTron 사용)
+ const generatePdf = async () => {
+ if (!uploadedFile || !contractSummary) {
+ toast.error('업로드된 파일과 계약 정보가 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // PDFTron을 사용해서 변수 치환 및 PDF 변환
+ // @ts-expect-error - PDFTron WebViewer dynamic import
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 임시 WebViewer 인스턴스 생성 (DOM에 추가하지 않음)
+ const tempDiv = document.createElement('div')
+ tempDiv.style.display = 'none'
+ document.body.appendChild(tempDiv)
+
+ const instance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ tempDiv
+ )
+
+ try {
+ const { Core } = instance
+ const { createDocument } = Core
+
+ // 템플릿 문서 생성 및 변수 치환
+ const templateDoc = await createDocument(uploadedFile, {
+ filename: uploadedFile.name,
+ extension: 'docx',
+ })
+
+ // LOI 템플릿용 변수 매핑
+ const mappedTemplateData = mapContractSummaryToLOITemplate(contractSummary)
+
+ console.log("🔄 변수 치환 시작:", mappedTemplateData)
+ await templateDoc.applyTemplateValues(mappedTemplateData as Record<string, unknown>)
+ console.log("✅ 변수 치환 완료")
+
+ // PDF 변환
+ const fileData = await templateDoc.getFileData()
+ const pdfBuffer = await (Core as { officeToPDFBuffer: (data: unknown, options: { extension: string }) => Promise<Uint8Array> }).officeToPDFBuffer(fileData, { extension: 'docx' })
+
+ console.log(`✅ PDF 변환 완료: ${uploadedFile.name}`, `크기: ${pdfBuffer.byteLength} bytes`)
+
+ // PDF 버퍼를 Blob URL로 변환하여 미리보기
+ const pdfBlob = new Blob([pdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ setGeneratedPdfUrl(pdfUrl)
+
+ // PDF 버퍼를 상태에 저장 (최종 전송 시 사용)
+ setGeneratedPdfBuffer(new Uint8Array(pdfBuffer))
+
+ toast.success('PDF가 생성되었습니다.')
+
+ } finally {
+ // 임시 WebViewer 정리
+ instance.UI.dispose()
+ document.body.removeChild(tempDiv)
+ }
+
+ } catch (error) {
+ console.error('❌ PDF 생성 실패:', error)
+ toast.error('PDF 생성 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 미리보기 기능
+ const openPdfPreview = async () => {
+ if (!generatedPdfBuffer) {
+ toast.error('생성된 PDF가 없습니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // @ts-expect-error - PDFTron WebViewer dynamic import
+ const WebViewer = await import("@pdftron/webviewer").then(({ default: WebViewer }) => WebViewer)
+
+ // 기존 인스턴스가 있다면 정리
+ if (pdfViewerInstance) {
+ console.log("🔄 기존 WebViewer 인스턴스 정리")
+ try {
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('기존 WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 미리보기용 컨테이너 확인
+ let previewDiv = document.getElementById('pdf-preview-container-review')
+ if (!previewDiv) {
+ console.log("🔄 컨테이너 생성")
+ previewDiv = document.createElement('div')
+ previewDiv.id = 'pdf-preview-container-review'
+ previewDiv.className = 'w-full h-full'
+ previewDiv.style.width = '100%'
+ previewDiv.style.height = '100%'
+
+ // 실제 컨테이너에 추가
+ const actualContainer = document.querySelector('[data-pdf-container-review]')
+ if (actualContainer) {
+ actualContainer.appendChild(previewDiv)
+ }
+ }
+
+ console.log("🔄 WebViewer 인스턴스 생성 시작")
+
+ // WebViewer 인스턴스 생성 (문서 없이)
+ const instance = await Promise.race([
+ WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ fullAPI: true,
+ },
+ previewDiv
+ ),
+ new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('WebViewer 초기화 타임아웃')), 30000)
+ )
+ ])
+
+ console.log("🔄 WebViewer 인스턴스 생성 완료")
+ setPdfViewerInstance(instance)
+
+ // PDF 버퍼를 Blob으로 변환
+ const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+ console.log("🔄 PDF Blob URL 생성:", pdfUrl)
+
+ // 문서 로드
+ console.log("🔄 문서 로드 시작")
+ const { documentViewer } = instance.Core
+
+ // 문서 로드 이벤트 대기
+ await new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ reject(new Error('문서 로드 타임아웃'))
+ }, 20000)
+
+ const onDocumentLoaded = () => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.log("🔄 문서 로드 완료")
+ resolve(true)
+ }
+
+ const onDocumentError = (error: Error) => {
+ clearTimeout(timeout)
+ documentViewer.removeEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.removeEventListener('documentError', onDocumentError)
+ console.error('문서 로드 오류:', error)
+ reject(error)
+ }
+
+ documentViewer.addEventListener('documentLoaded', onDocumentLoaded)
+ documentViewer.addEventListener('documentError', onDocumentError)
+
+ // 문서 로드 시작
+ documentViewer.loadDocument(pdfUrl)
+ })
+
+ setIsPdfPreviewVisible(true)
+ toast.success('PDF 미리보기가 준비되었습니다.')
+
+ } catch (error) {
+ console.error('PDF 미리보기 실패:', error)
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'
+ toast.error(`PDF 미리보기 중 오류가 발생했습니다: ${errorMessage}`)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // PDF 다운로드 기능
+ const downloadPdf = () => {
+ if (!generatedPdfBuffer) {
+ toast.error('다운로드할 PDF가 없습니다.')
+ return
+ }
+
+ const pdfBlob = new Blob([generatedPdfBuffer], { type: 'application/pdf' })
+ const pdfUrl = URL.createObjectURL(pdfBlob)
+
+ const link = document.createElement('a')
+ link.href = pdfUrl
+ link.download = `contract_review_${contractId}_${Date.now()}.pdf`
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+
+ URL.revokeObjectURL(pdfUrl)
+ toast.success('PDF가 다운로드되었습니다.')
+ }
+
+ // PDF 미리보기 닫기
+ const closePdfPreview = () => {
+ console.log("🔄 PDF 미리보기 닫기 시작")
+ if (pdfViewerInstance) {
+ try {
+ console.log("🔄 WebViewer 인스턴스 정리")
+ pdfViewerInstance.UI.dispose()
+ } catch (error) {
+ console.warn('WebViewer 정리 중 오류:', error)
+ }
+ setPdfViewerInstance(null)
+ }
+
+ // 컨테이너 정리
+ const previewDiv = document.getElementById('pdf-preview-container-review')
+ if (previewDiv) {
+ try {
+ previewDiv.innerHTML = ''
+ } catch (error) {
+ console.warn('컨테이너 정리 중 오류:', error)
+ }
+ }
+
+ setIsPdfPreviewVisible(false)
+ console.log("🔄 PDF 미리보기 닫기 완료")
+ }
+
+ // 최종 전송
+ const handleFinalSubmit = async () => {
+ if (!generatedPdfUrl || !contractSummary || !generatedPdfBuffer) {
+ toast.error('생성된 PDF가 필요합니다.')
+ return
+ }
+
+ if (!userId) {
+ toast.error('로그인이 필요합니다.')
+ return
+ }
+
+ setIsLoading(true)
+ try {
+ // 서버액션을 사용하여 조건검토요청 전송
+ const result = await sendContractReviewRequest(
+ contractSummary,
+ generatedPdfBuffer,
+ contractId,
+ userId
+ )
+
+ if (result.success) {
+ toast.success('조건검토요청이 전송되었습니다.')
+ onOpenChange(false)
+ // 페이지 새로고침을 위해 window.location.reload() 호출
+ window.location.reload()
+ } else {
+ // 서버에서 이미 처리된 에러 메시지 표시
+ toast.error(result.error || '조건검토요청 전송 실패')
+ return
+ }
+ } catch (error) {
+ console.error('Error submitting review request:', error)
+ toast.error('조건검토요청 전송 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 다이얼로그가 열릴 때 1단계 데이터 수집
+ useEffect(() => {
+ if (open && currentStep === 1) {
+ collectContractSummary()
+ }
+ }, [open, currentStep, collectContractSummary])
+
+ // 다이얼로그가 닫힐 때 PDF 뷰어 정리
+ useEffect(() => {
+ if (!open) {
+ closePdfPreview()
+ // 상태 초기화
+ setCurrentStep(1)
+ setUploadedFile(null)
+ setGeneratedPdfUrl(null)
+ setGeneratedPdfBuffer(null)
+ setIsPdfPreviewVisible(false)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open])
+
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 조건검토요청
+ </DialogTitle>
+ </DialogHeader>
+
+ <Tabs value={currentStep.toString()} className="w-full">
+ <TabsList className="grid w-full grid-cols-3">
+ <TabsTrigger value="1" disabled={currentStep < 1}>
+ 1. 미리보기
+ </TabsTrigger>
+ <TabsTrigger value="2" disabled={currentStep < 2}>
+ 2. 템플릿 업로드
+ </TabsTrigger>
+ <TabsTrigger value="3" disabled={currentStep < 3}>
+ 3. PDF 미리보기
+ </TabsTrigger>
+ </TabsList>
+
+ {/* 1단계: 계약 현황 정리 */}
+ <TabsContent value="1" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <CheckCircle className="h-5 w-5 text-green-600" />
+ 작성된 계약 현황
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {isLoading ? (
+ <div className="text-center py-4">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto"></div>
+ <p className="mt-2 text-sm text-muted-foreground">계약 정보를 수집하는 중...</p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ {/* 기본 정보 (필수) */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">기본 정보</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">계약번호:</span> {String(contractSummary?.basicInfo?.contractNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약명:</span> {String(contractSummary?.basicInfo?.contractName || '')}
+ </div>
+ <div>
+ <span className="font-medium">벤더:</span> {String(contractSummary?.basicInfo?.vendorName || '')}
+ </div>
+ <div>
+ <span className="font-medium">프로젝트:</span> {String(contractSummary?.basicInfo?.projectName || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약유형:</span> {String(contractSummary?.basicInfo?.contractType || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약상태:</span> {String(contractSummary?.basicInfo?.contractStatus || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약금액:</span> {String(contractSummary?.basicInfo?.contractAmount || '')} {String(contractSummary?.basicInfo?.currency || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약기간:</span> {String(contractSummary?.basicInfo?.startDate || '')} ~ {String(contractSummary?.basicInfo?.endDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">사양서 유형:</span> {String(contractSummary?.basicInfo?.specificationType || '')}
+ </div>
+ <div>
+ <span className="font-medium">단가 유형:</span> {String(contractSummary?.basicInfo?.unitPriceType || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 PO번호:</span> {String(contractSummary?.basicInfo?.linkedPoNumber || '')}
+ </div>
+ <div>
+ <span className="font-medium">연결 입찰번호:</span> {String(contractSummary?.basicInfo?.linkedBidNumber || '')}
+ </div>
+ </div>
+ </div>
+
+ {/* 지급/인도 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">지급/인도 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">지급조건:</span> {String(contractSummary?.basicInfo?.paymentTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">세금 유형:</span> {String(contractSummary?.basicInfo?.taxType || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도조건:</span> {String(contractSummary?.basicInfo?.deliveryTerm || '')}
+ </div>
+ <div>
+ <span className="font-medium">인도유형:</span> {String(contractSummary?.basicInfo?.deliveryType || '')}
+ </div>
+ <div>
+ <span className="font-medium">선적지:</span> {String(contractSummary?.basicInfo?.shippingLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">하역지:</span> {String(contractSummary?.basicInfo?.dischargeLocation || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약납기:</span> {String(contractSummary?.basicInfo?.contractDeliveryDate || '')}
+ </div>
+ <div>
+ <span className="font-medium">위약금:</span> {contractSummary?.basicInfo?.liquidatedDamages ? '적용' : '미적용'}
+ </div>
+ </div>
+ </div>
+
+ {/* 추가 조건 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <Label className="font-medium">추가 조건</Label>
+ <Badge variant="secondary">필수</Badge>
+ </div>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">연동제 정보:</span> {String(contractSummary?.basicInfo?.interlockingSystem || '')}
+ </div>
+ <div>
+ <span className="font-medium">계약성립조건:</span>
+ {contractSummary?.basicInfo?.contractEstablishmentConditions &&
+ Object.entries(contractSummary.basicInfo.contractEstablishmentConditions)
+ .filter(([, value]) => value === true)
+ .map(([key]) => key)
+ .join(', ') || '없음'}
+ </div>
+ <div>
+ <span className="font-medium">계약해지조건:</span>
+ {contractSummary?.basicInfo?.contractTerminationConditions &&
+ Object.entries(contractSummary.basicInfo.contractTerminationConditions)
+ .filter(([, value]) => value === true)
+ .map(([key]) => key)
+ .join(', ') || '없음'}
+ </div>
+ </div>
+ </div>
+
+ {/* 품목 정보 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="items-enabled"
+ checked={contractSummary?.items && contractSummary.items.length > 0}
+ disabled
+ />
+ <Label htmlFor="items-enabled" className="font-medium">품목 정보</Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ {contractSummary?.items && contractSummary.items.length > 0 ? (
+ <div className="space-y-2">
+ <p className="text-sm text-muted-foreground">
+ 총 {contractSummary.items.length}개 품목이 입력되어 있습니다.
+ </p>
+ <div className="max-h-32 overflow-y-auto">
+ {contractSummary.items.slice(0, 3).map((item: Record<string, unknown>, index: number) => (
+ <div key={index} className="text-xs bg-gray-50 p-2 rounded">
+ <div className="font-medium">{item.itemInfo || item.description || `품목 ${index + 1}`}</div>
+ <div className="text-muted-foreground">
+ 수량: {item.quantity || 0} | 단가: {item.contractUnitPrice || item.unitPrice || 0}
+ </div>
+ </div>
+ ))}
+ {contractSummary.items.length > 3 && (
+ <div className="text-xs text-muted-foreground text-center">
+ ... 외 {contractSummary.items.length - 3}개 품목
+ </div>
+ )}
+ </div>
+ </div>
+ ) : (
+ <p className="text-sm text-muted-foreground">
+ 품목 정보가 입력되지 않았습니다.
+ </p>
+ )}
+ </div>
+
+ {/* 하도급 체크리스트 */}
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center gap-2 mb-3">
+ <Checkbox
+ id="subcontract-enabled"
+ checked={!!contractSummary?.subcontractChecklist}
+ disabled
+ />
+ <Label htmlFor="subcontract-enabled" className="font-medium">
+ 하도급 체크리스트
+ </Label>
+ <Badge variant="outline">선택</Badge>
+ </div>
+ <p className="text-sm text-muted-foreground">
+ {contractSummary?.subcontractChecklist
+ ? '정보가 입력되어 있습니다.'
+ : '정보가 입력되지 않았습니다.'}
+ </p>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-end">
+ <Button
+ onClick={() => setCurrentStep(2)}
+ disabled={isLoading}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 2단계: 문서 업로드 */}
+ <TabsContent value="2" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Upload className="h-5 w-5 text-blue-600" />
+ 계약서 템플릿 업로드
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="space-y-4">
+ <p className="text-lg text-muted-foreground">일반계약 표준문서 관리 페이지에 접속하여, 원하는 양식의 계약서를 다운받아 수정 후 업로드하세요.</p>
+ <div>
+ <Label htmlFor="file-upload-review">파일 업로드</Label>
+ <Input
+ id="file-upload-review"
+ type="file"
+ accept=".doc,.docx"
+ onChange={(e) => {
+ const file = e.target.files?.[0]
+ if (file) handleFileUpload(file)
+ }}
+ />
+ <p className="text-sm text-muted-foreground mt-1">
+ Word 문서(.doc, .docx) 파일만 업로드 가능합니다.
+ </p>
+ </div>
+
+ {uploadedFile && (
+ <div className="border rounded-lg p-4 bg-green-50">
+ <div className="flex items-center gap-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900">업로드 완료</span>
+ </div>
+ <p className="text-sm text-green-800 mt-1">{uploadedFile.name}</p>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(1)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={() => setCurrentStep(3)}
+ disabled={!uploadedFile}
+ >
+ 다음 단계
+ </Button>
+ </div>
+ </TabsContent>
+
+ {/* 3단계: PDF 미리보기 */}
+ <TabsContent value="3" className="space-y-4">
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Eye className="h-5 w-5 text-purple-600" />
+ PDF 미리보기
+ </CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {!generatedPdfUrl ? (
+ <div className="text-center py-8">
+ <Button onClick={generatePdf} disabled={isLoading}>
+ {isLoading ? 'PDF 생성 중...' : 'PDF 생성하기'}
+ </Button>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="border rounded-lg p-4 bg-green-50">
+ <div className="flex items-center gap-2">
+ <CheckCircle className="h-4 w-4 text-green-600" />
+ <span className="font-medium text-green-900">PDF 생성 완료</span>
+ </div>
+ </div>
+
+ <div className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-4">
+ <h4 className="font-medium">생성된 PDF</h4>
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={downloadPdf}
+ disabled={isLoading}
+ >
+ <Download className="h-4 w-4 mr-2" />
+ 다운로드
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={openPdfPreview}
+ disabled={isLoading}
+ >
+ <Eye className="h-4 w-4 mr-2" />
+ 미리보기
+ </Button>
+ </div>
+ </div>
+
+ {/* PDF 미리보기 영역 */}
+ <div className="border rounded-lg h-96 bg-gray-50 relative" data-pdf-container-review>
+ {isPdfPreviewVisible ? (
+ <>
+ <div className="absolute top-2 right-2 z-10">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={closePdfPreview}
+ className="bg-white/90 hover:bg-white"
+ >
+ ✕ 닫기
+ </Button>
+ </div>
+ <div id="pdf-preview-container-review" className="w-full h-full" />
+ </>
+ ) : (
+ <div className="flex items-center justify-center h-full">
+ <div className="text-center text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-2" />
+ <p>미리보기 버튼을 클릭하여 PDF를 확인하세요</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+
+ <div className="flex justify-between">
+ <Button variant="outline" onClick={() => setCurrentStep(2)}>
+ 이전 단계
+ </Button>
+ <Button
+ onClick={handleFinalSubmit}
+ disabled={!generatedPdfUrl || isLoading}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ <Send className="h-4 w-4 mr-2" />
+ {isLoading ? '전송 중...' : '조건검토요청 전송'}
+ </Button>
+ </div>
+ </TabsContent>
+ </Tabs>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/general-contracts/detail/general-contract-storage-info.tsx b/lib/general-contracts/detail/general-contract-storage-info.tsx
new file mode 100644
index 00000000..2c9b230c
--- /dev/null
+++ b/lib/general-contracts/detail/general-contract-storage-info.tsx
@@ -0,0 +1,249 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
+import { Save, Plus, Trash2, LoaderIcon } from 'lucide-react'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+import { getStorageInfo, saveStorageInfo } from '../service'
+
+interface StorageInfoItem {
+ id?: number
+ poNumber: string
+ hullNumber: string
+ remainingAmount: number
+}
+
+interface ContractStorageInfoProps {
+ contractId: number
+ readOnly?: boolean
+}
+
+export function ContractStorageInfo({ contractId, readOnly = false }: ContractStorageInfoProps) {
+ const session = useSession()
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [items, setItems] = useState<StorageInfoItem[]>([])
+
+ // 데이터 로드
+ useEffect(() => {
+ const loadStorageInfo = async () => {
+ setIsLoading(true)
+ try {
+ const data = await getStorageInfo(contractId)
+ setItems(data || [])
+ } catch (error) {
+ console.error('Error loading storage info:', error)
+ toast.error('임치계약 정보를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (contractId) {
+ loadStorageInfo()
+ }
+ }, [contractId])
+
+ // 행 추가
+ const addRow = () => {
+ setItems(prev => [...prev, {
+ poNumber: '',
+ hullNumber: '',
+ remainingAmount: 0
+ }])
+ }
+
+ // 행 삭제
+ const deleteRow = (index: number) => {
+ setItems(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 항목 업데이트
+ const updateItem = (index: number, field: keyof StorageInfoItem, value: string | number) => {
+ setItems(prev => prev.map((item, i) =>
+ i === index ? { ...item, [field]: value } : item
+ ))
+ }
+
+ // 저장
+ const handleSave = async () => {
+ if (!userId) {
+ toast.error('사용자 정보를 찾을 수 없습니다.')
+ return
+ }
+
+ // 유효성 검사
+ const errors: string[] = []
+ items.forEach((item, index) => {
+ if (!item.poNumber.trim()) errors.push(`${index + 1}번째 행의 PO No.`)
+ if (!item.hullNumber.trim()) errors.push(`${index + 1}번째 행의 호선`)
+ if (item.remainingAmount < 0) errors.push(`${index + 1}번째 행의 미입고 잔여금액`)
+ })
+
+ if (errors.length > 0) {
+ toast.error(`다음 항목을 확인해주세요: ${errors.join(', ')}`)
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ await saveStorageInfo(contractId, items, userId)
+ toast.success('임치계약 정보가 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving storage info:', error)
+ toast.error('임치계약 정보 저장 중 오류가 발생했습니다.')
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ if (isLoading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>임치(물품보관)계약 상세 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <LoaderIcon className="w-6 h-6 animate-spin mr-2" />
+ <span>로딩 중...</span>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle>임치(물품보관)계약 상세 정보</CardTitle>
+ {!readOnly && (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={addRow}
+ >
+ <Plus className="w-4 h-4 mr-2" />
+ 행 추가
+ </Button>
+ <Button
+ onClick={handleSave}
+ disabled={isSaving}
+ >
+ {isSaving ? (
+ <>
+ <LoaderIcon className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ </div>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent>
+ {items.length === 0 ? (
+ <div className="text-center py-8 text-muted-foreground">
+ <p>등록된 정보가 없습니다.</p>
+ {!readOnly && (
+ <Button
+ variant="outline"
+ className="mt-4"
+ onClick={addRow}
+ >
+ <Plus className="w-4 h-4 mr-2" />
+ 정보 추가
+ </Button>
+ )}
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <Table>
+ <TableHeader>
+ <TableRow>
+ <TableHead className="w-12">번호</TableHead>
+ <TableHead>PO No.</TableHead>
+ <TableHead>호선</TableHead>
+ <TableHead className="text-right">미입고 잔여금액</TableHead>
+ {!readOnly && <TableHead className="w-20">삭제</TableHead>}
+ </TableRow>
+ </TableHeader>
+ <TableBody>
+ {items.map((item, index) => (
+ <TableRow key={index}>
+ <TableCell>{index + 1}</TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="text-sm">{item.poNumber || '-'}</span>
+ ) : (
+ <Input
+ value={item.poNumber}
+ onChange={(e) => updateItem(index, 'poNumber', e.target.value)}
+ placeholder="PO No. 입력"
+ className="h-8 text-sm"
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="text-sm">{item.hullNumber || '-'}</span>
+ ) : (
+ <Input
+ value={item.hullNumber}
+ onChange={(e) => updateItem(index, 'hullNumber', e.target.value)}
+ placeholder="호선 입력"
+ className="h-8 text-sm"
+ />
+ )}
+ </TableCell>
+ <TableCell>
+ {readOnly ? (
+ <span className="text-sm text-right block">
+ {item.remainingAmount.toLocaleString()}
+ </span>
+ ) : (
+ <Input
+ type="number"
+ value={item.remainingAmount}
+ onChange={(e) => updateItem(index, 'remainingAmount', parseFloat(e.target.value) || 0)}
+ placeholder="0"
+ className="h-8 text-sm text-right"
+ min="0"
+ />
+ )}
+ </TableCell>
+ {!readOnly && (
+ <TableCell>
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => deleteRow(index)}
+ className="text-red-600 hover:text-red-700"
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ </TableCell>
+ )}
+ </TableRow>
+ ))}
+ </TableBody>
+ </Table>
+ </div>
+ )}
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx b/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx
index ce7c8baf..86c4485b 100644
--- a/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx
+++ b/lib/general-contracts/detail/general-contract-subcontract-checklist.tsx
@@ -50,9 +50,21 @@ interface SubcontractChecklistProps {
onDataChange: (data: SubcontractChecklistData) => void
readOnly?: boolean
initialData?: SubcontractChecklistData
+ contractType?: string // 계약종류 (AD, AW, SG 등)
+ vendorCountry?: string // 협력업체 국가 (해외업체 여부 판단)
}
-export function SubcontractChecklist({ contractId, onDataChange, readOnly = false, initialData }: SubcontractChecklistProps) {
+export function SubcontractChecklist({
+ contractId,
+ onDataChange,
+ readOnly = false,
+ initialData,
+ contractType = '',
+ vendorCountry = ''
+}: SubcontractChecklistProps) {
+ // AD, AW, SG 계약 또는 해외업체인 경우 체크리스트 비활성화
+ const isChecklistDisabled = contractType === 'AD' || contractType === 'AW' || contractType === 'SG' || vendorCountry !== 'KR'
+
// 기본 데이터 구조
const defaultData: SubcontractChecklistData = {
contractDocumentIssuance: {
@@ -96,9 +108,16 @@ export function SubcontractChecklist({ contractId, onDataChange, readOnly = fals
}
}, [initialData])
- const [isEnabled, setIsEnabled] = useState(true)
+ const [isEnabled, setIsEnabled] = useState(!isChecklistDisabled)
const [data, setData] = useState<SubcontractChecklistData>(mergedInitialData)
+ // 체크리스트가 비활성화된 경우 경고 메시지 표시
+ React.useEffect(() => {
+ if (isChecklistDisabled) {
+ setIsEnabled(false)
+ }
+ }, [isChecklistDisabled])
+
// 점검결과 자동 계산 함수
const calculateInspectionResult = (
contractDocumentIssuance: SubcontractChecklistData['contractDocumentIssuance'],
@@ -249,20 +268,36 @@ export function SubcontractChecklist({ contractId, onDataChange, readOnly = fals
<div className="flex items-center gap-3 w-full">
<HelpCircle className="h-5 w-5" />
<span className="font-medium">하도급법 자율점검 체크리스트</span>
- <Badge className={resultInfo.color}>
- {resultInfo.label}
- </Badge>
+ {isChecklistDisabled ? (
+ <Badge className="bg-gray-100 text-gray-800">비활성화</Badge>
+ ) : (
+ <Badge className={resultInfo.color}>
+ {resultInfo.label}
+ </Badge>
+ )}
</div>
</AccordionTrigger>
<AccordionContent>
<Card>
<CardContent className="space-y-6 pt-6">
+ {/* 체크리스트 비활성화 안내 */}
+ {isChecklistDisabled && (
+ <Alert>
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ {contractType === 'AD' || contractType === 'AW' || contractType === 'SG'
+ ? `본 계약은 ${contractType === 'AD' ? '사전납품합의' : contractType === 'AW' ? '사전작업합의' : '임치(물품보관)계약'} 계약으로 하도급법 체크리스트가 적용되지 않습니다.`
+ : '해외업체 계약으로 하도급법 체크리스트가 적용되지 않습니다.'}
+ </AlertDescription>
+ </Alert>
+ )}
+
{/* 체크박스 */}
<div className="flex items-center gap-2">
<Checkbox
checked={isEnabled}
onCheckedChange={(checked) => setIsEnabled(checked as boolean)}
- disabled={readOnly}
+ disabled={readOnly || isChecklistDisabled}
/>
<span className="text-sm font-medium">하도급법 자율점검 체크리스트 활성화</span>
</div>
diff --git a/lib/general-contracts/detail/general-contract-yard-entry-info.tsx b/lib/general-contracts/detail/general-contract-yard-entry-info.tsx
new file mode 100644
index 00000000..1fb1e310
--- /dev/null
+++ b/lib/general-contracts/detail/general-contract-yard-entry-info.tsx
@@ -0,0 +1,232 @@
+'use client'
+
+import React, { useState, useEffect } from 'react'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Label } from '@/components/ui/label'
+import { Button } from '@/components/ui/button'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Save, LoaderIcon } from 'lucide-react'
+import { toast } from 'sonner'
+import { useSession } from 'next-auth/react'
+import { getProjects, getYardEntryInfo, saveYardEntryInfo } from '../service'
+
+interface YardEntryInfo {
+ projectId: number | null
+ projectCode: string
+ projectName: string
+ managerName: string
+ managerDepartment: string
+ rehandlingContractor: string
+}
+
+interface ContractYardEntryInfoProps {
+ contractId: number
+ readOnly?: boolean
+}
+
+export function ContractYardEntryInfo({ contractId, readOnly = false }: ContractYardEntryInfoProps) {
+ const session = useSession()
+ const userId = session.data?.user?.id ? Number(session.data.user.id) : null
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+ const [projects, setProjects] = useState<Array<{ id: number; code: string; name: string }>>([])
+ const [formData, setFormData] = useState<YardEntryInfo>({
+ projectId: null,
+ projectCode: '',
+ projectName: '',
+ managerName: '',
+ managerDepartment: '',
+ rehandlingContractor: ''
+ })
+
+ // 프로젝트 목록 로드
+ useEffect(() => {
+ const loadProjects = async () => {
+ try {
+ const projectList = await getProjects()
+ setProjects(projectList)
+ } catch (error) {
+ console.error('Error loading projects:', error)
+ }
+ }
+ loadProjects()
+ }, [])
+
+ // 데이터 로드
+ useEffect(() => {
+ const loadYardEntryInfo = async () => {
+ setIsLoading(true)
+ try {
+ const data = await getYardEntryInfo(contractId)
+ if (data) {
+ setFormData(data)
+ }
+ } catch (error) {
+ console.error('Error loading yard entry info:', error)
+ toast.error('사외업체 야드투입 정보를 불러오는 중 오류가 발생했습니다.')
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ if (contractId) {
+ loadYardEntryInfo()
+ }
+ }, [contractId])
+
+ // 저장
+ const handleSave = async () => {
+ if (!userId) {
+ toast.error('사용자 정보를 찾을 수 없습니다.')
+ return
+ }
+
+ // 유효성 검사
+ if (!formData.projectId) {
+ toast.error('프로젝트를 선택해주세요.')
+ return
+ }
+
+ setIsSaving(true)
+ try {
+ await saveYardEntryInfo(contractId, formData, userId)
+ toast.success('사외업체 야드투입 정보가 저장되었습니다.')
+ } catch (error) {
+ console.error('Error saving yard entry info:', error)
+ toast.error('사외업체 야드투입 정보 저장 중 오류가 발생했습니다.')
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ const selectedProject = projects.find(p => p.id === formData.projectId)
+
+ if (isLoading) {
+ return (
+ <Card>
+ <CardHeader>
+ <CardTitle>사외업체 야드투입 정보</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="flex items-center justify-center py-8">
+ <LoaderIcon className="w-6 h-6 animate-spin mr-2" />
+ <span>로딩 중...</span>
+ </div>
+ </CardContent>
+ </Card>
+ )
+ }
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle>사외업체 야드투입 정보</CardTitle>
+ {!readOnly && (
+ <Button
+ onClick={handleSave}
+ disabled={isSaving}
+ >
+ {isSaving ? (
+ <>
+ <LoaderIcon className="w-4 h-4 mr-2 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="w-4 h-4 mr-2" />
+ 저장
+ </>
+ )}
+ </Button>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 프로젝트 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="project">프로젝트 *</Label>
+ {readOnly ? (
+ <div className="text-sm">
+ {selectedProject ? `${selectedProject.code} - ${selectedProject.name}` : '-'}
+ </div>
+ ) : (
+ <Select
+ value={formData.projectId?.toString() || ''}
+ onValueChange={(value) => {
+ const projectId = parseInt(value)
+ const project = projects.find(p => p.id === projectId)
+ setFormData(prev => ({
+ ...prev,
+ projectId: projectId,
+ projectCode: project?.code || '',
+ projectName: project?.name || ''
+ }))
+ }}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="프로젝트 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.code} - {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ )}
+ </div>
+
+ {/* 관리담당자 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="managerName">관리담당자</Label>
+ {readOnly ? (
+ <div className="text-sm">{formData.managerName || '-'}</div>
+ ) : (
+ <Input
+ id="managerName"
+ value={formData.managerName}
+ onChange={(e) => setFormData(prev => ({ ...prev, managerName: e.target.value }))}
+ placeholder="관리담당자 입력"
+ />
+ )}
+ </div>
+
+ {/* 관리부서 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="managerDepartment">관리부서</Label>
+ {readOnly ? (
+ <div className="text-sm">{formData.managerDepartment || '-'}</div>
+ ) : (
+ <Input
+ id="managerDepartment"
+ value={formData.managerDepartment}
+ onChange={(e) => setFormData(prev => ({ ...prev, managerDepartment: e.target.value }))}
+ placeholder="관리부서 입력"
+ />
+ )}
+ </div>
+
+ {/* 재하도협력사 */}
+ <div className="flex flex-col gap-2">
+ <Label htmlFor="rehandlingContractor">재하도협력사</Label>
+ {readOnly ? (
+ <div className="text-sm">{formData.rehandlingContractor || '-'}</div>
+ ) : (
+ <Input
+ id="rehandlingContractor"
+ value={formData.rehandlingContractor}
+ onChange={(e) => setFormData(prev => ({ ...prev, rehandlingContractor: e.target.value }))}
+ placeholder="재하도협력사 입력"
+ />
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
+
diff --git a/lib/general-contracts/main/create-general-contract-dialog.tsx b/lib/general-contracts/main/create-general-contract-dialog.tsx
index 2c3fc8bc..168b8cbc 100644
--- a/lib/general-contracts/main/create-general-contract-dialog.tsx
+++ b/lib/general-contracts/main/create-general-contract-dialog.tsx
@@ -20,11 +20,12 @@ import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
-import { CalendarIcon } from "lucide-react"
+import { CalendarIcon, Check, ChevronsUpDown } from "lucide-react"
+import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
import { format } from "date-fns"
import { ko } from "date-fns/locale"
import { cn } from "@/lib/utils"
-import { createContract, getVendors, getProjects } from "@/lib/general-contracts/service"
+import { createContract, getVendors } from "@/lib/general-contracts/service"
import {
GENERAL_CONTRACT_CATEGORIES,
GENERAL_CONTRACT_TYPES,
@@ -39,7 +40,6 @@ interface CreateContractForm {
type: string
executionMethod: string
vendorId: number | null
- projectId: number | null
startDate: Date | undefined
endDate: Date | undefined
validityEndDate: Date | undefined
@@ -52,7 +52,8 @@ export function CreateGeneralContractDialog() {
const [open, setOpen] = React.useState(false)
const [isLoading, setIsLoading] = React.useState(false)
const [vendors, setVendors] = React.useState<Array<{ id: number; vendorName: string; vendorCode: string | null }>>([])
- const [projects, setProjects] = React.useState<Array<{ id: number; code: string; name: string; type: string }>>([])
+ const [vendorSearchTerm, setVendorSearchTerm] = React.useState("")
+ const [vendorOpen, setVendorOpen] = React.useState(false)
const [form, setForm] = React.useState<CreateContractForm>({
contractNumber: '',
@@ -61,7 +62,6 @@ export function CreateGeneralContractDialog() {
type: '',
executionMethod: '',
vendorId: null,
- projectId: null,
startDate: undefined,
endDate: undefined,
validityEndDate: undefined,
@@ -70,36 +70,54 @@ export function CreateGeneralContractDialog() {
// 업체 목록 조회
React.useEffect(() => {
- const fetchVendors = async () => {
+ const fetchData = async () => {
try {
const vendorList = await getVendors()
+ console.log('vendorList', vendorList)
setVendors(vendorList)
} catch (error) {
- console.error('Error fetching vendors:', error)
+ console.error('데이터 조회 오류:', error)
+ toast.error('데이터를 불러오는데 실패했습니다')
+ setVendors([])
}
}
- fetchVendors()
+ fetchData()
}, [])
- // 프로젝트 목록 조회
- React.useEffect(() => {
- const fetchProjects = async () => {
- try {
- const projectList = await getProjects()
- console.log(projectList)
- setProjects(projectList)
- } catch (error) {
- console.error('Error fetching projects:', error)
- }
- }
- fetchProjects()
- }, [])
+ // 협력업체 검색 필터링
+ const filteredVendors = React.useMemo(() => {
+ if (!vendorSearchTerm.trim()) return vendors
+ const lowerSearch = vendorSearchTerm.toLowerCase()
+ return vendors.filter(
+ vendor =>
+ vendor.vendorName.toLowerCase().includes(lowerSearch) ||
+ (vendor.vendorCode && vendor.vendorCode.toLowerCase().includes(lowerSearch))
+ )
+ }, [vendors, vendorSearchTerm])
const handleSubmit = async () => {
// 필수 필드 검증
- if (!form.name || !form.category || !form.type || !form.executionMethod ||
- !form.vendorId || !form.startDate || !form.endDate) {
- toast.error("필수 항목을 모두 입력해주세요.")
+ const validationErrors: string[] = []
+
+ if (!form.name) validationErrors.push('계약명')
+ if (!form.category) validationErrors.push('계약구분')
+ if (!form.type) validationErrors.push('계약종류')
+ if (!form.executionMethod) validationErrors.push('체결방식')
+ if (!form.vendorId) validationErrors.push('협력업체')
+
+ // AD, LO, OF 계약이 아닌 경우에만 계약기간 필수값 체크
+ if (!['AD', 'LO', 'OF'].includes(form.type)) {
+ if (!form.startDate) validationErrors.push('계약시작일')
+ if (!form.endDate) validationErrors.push('계약종료일')
+ }
+
+ // LO 계약인 경우 계약체결유효기간 필수값 체크
+ if (form.type === 'LO' && !form.validityEndDate) {
+ validationErrors.push('유효기간')
+ }
+
+ if (validationErrors.length > 0) {
+ toast.error(`다음 필수 항목을 입력해주세요: ${validationErrors.join(', ')}`)
return
}
@@ -116,7 +134,6 @@ export function CreateGeneralContractDialog() {
category: form.category,
type: form.type,
executionMethod: form.executionMethod,
- projectId: form.projectId,
contractSourceType: 'manual',
vendorId: form.vendorId!,
startDate: form.startDate!.toISOString().split('T')[0],
@@ -152,7 +169,6 @@ export function CreateGeneralContractDialog() {
type: '',
executionMethod: '',
vendorId: null,
- projectId: null,
startDate: undefined,
endDate: undefined,
validityEndDate: undefined,
@@ -231,15 +247,14 @@ export function CreateGeneralContractDialog() {
'AL': '연간운송계약',
'OS': '외주용역계약',
'OW': '도급계약',
- 'IS': '검사계약',
'LO': 'LOI',
'FA': 'FA',
'SC': '납품합의계약',
'OF': '클레임상계계약',
'AW': '사전작업합의',
'AD': '사전납품합의',
- 'AM': '설계계약',
- 'SC_SELL': '폐기물매각계약'
+ 'SG': '임치(물품보관)계약',
+ 'SR': '폐기물매각계약'
}
return (
<SelectItem key={type} value={type}>
@@ -269,35 +284,62 @@ export function CreateGeneralContractDialog() {
</div>
<div className="grid gap-2">
- <Label htmlFor="project">프로젝트</Label>
- <Select value={form.projectId?.toString()} onValueChange={(value) => setForm(prev => ({ ...prev, projectId: parseInt(value) }))}>
- <SelectTrigger>
- <SelectValue placeholder="프로젝트 선택 (선택사항)" />
- </SelectTrigger>
- <SelectContent>
- {projects.map((project) => (
- <SelectItem key={project.id} value={project.id.toString()}>
- {project.name} ({project.code})
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
-
- <div className="grid gap-2">
<Label htmlFor="vendor">협력업체 *</Label>
- <Select value={form.vendorId?.toString()} onValueChange={(value) => setForm(prev => ({ ...prev, vendorId: parseInt(value) }))}>
- <SelectTrigger>
- <SelectValue placeholder="협력업체 선택" />
- </SelectTrigger>
- <SelectContent>
- {vendors.map((vendor) => (
- <SelectItem key={vendor.id} value={vendor.id.toString()}>
- {vendor.vendorName} ({vendor.vendorCode})
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <Popover open={vendorOpen} onOpenChange={setVendorOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={vendorOpen}
+ className="w-full justify-between"
+ >
+ {form.vendorId ? (
+ (() => {
+ const selected = vendors.find(v => v.id === form.vendorId)
+ return selected ? `${selected.vendorName} (${selected.vendorCode || ''})` : "협력업체 선택"
+ })()
+ ) : (
+ "협력업체 선택"
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0">
+ <Command>
+ <CommandInput
+ placeholder="협력업체명/코드 검색..."
+ onValueChange={setVendorSearchTerm}
+ />
+ <CommandList className="max-h-[300px]">
+ <CommandEmpty>검색 결과가 없습니다</CommandEmpty>
+ <CommandGroup>
+ {filteredVendors.map((vendor) => (
+ <CommandItem
+ key={vendor.id}
+ value={`${vendor.vendorName} ${vendor.vendorCode || ''}`}
+ onSelect={() => {
+ setForm(prev => ({ ...prev, vendorId: vendor.id }))
+ setVendorOpen(false)
+ setVendorSearchTerm("")
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ form.vendorId === vendor.id ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <span className="font-medium">{vendor.vendorName}</span>
+ {vendor.vendorCode && (
+ <span className="ml-2 text-gray-500">({vendor.vendorCode})</span>
+ )}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
</div>
<div className="grid grid-cols-3 gap-4">
diff --git a/lib/general-contracts/main/general-contract-update-sheet.tsx b/lib/general-contracts/main/general-contract-update-sheet.tsx
index 54f4ae4e..18095516 100644
--- a/lib/general-contracts/main/general-contract-update-sheet.tsx
+++ b/lib/general-contracts/main/general-contract-update-sheet.tsx
@@ -44,14 +44,41 @@ const updateContractSchema = z.object({
type: z.string().min(1, "계약종류를 선택해주세요"),
executionMethod: z.string().min(1, "체결방식을 선택해주세요"),
name: z.string().min(1, "계약명을 입력해주세요"),
- startDate: z.string().min(1, "계약시작일을 선택해주세요"),
- endDate: z.string().min(1, "계약종료일을 선택해주세요"),
- validityEndDate: z.string().min(1, "유효기간종료일을 선택해주세요"),
+ startDate: z.string().optional(), // AD, LO, OF 계약인 경우 선택사항
+ endDate: z.string().optional(), // AD, LO, OF 계약인 경우 선택사항
+ validityEndDate: z.string().optional(), // LO 계약인 경우에만 필수값으로 처리
contractScope: z.string().min(1, "계약확정범위를 선택해주세요"),
notes: z.string().optional(),
linkedRfqOrItb: z.string().optional(),
linkedPoNumber: z.string().optional(),
linkedBidNumber: z.string().optional(),
+}).superRefine((data, ctx) => {
+ // AD, LO, OF 계약이 아닌 경우 계약기간 필수값 체크
+ if (!['AD', 'LO', 'OF'].includes(data.type)) {
+ if (!data.startDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "계약시작일을 선택해주세요",
+ path: ["startDate"],
+ })
+ }
+ if (!data.endDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "계약종료일을 선택해주세요",
+ path: ["endDate"],
+ })
+ }
+ }
+
+ // LO 계약인 경우 계약체결유효기간 필수값 체크
+ if (data.type === 'LO' && !data.validityEndDate) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "LO 계약의 경우 계약체결유효기간은 필수 항목입니다",
+ path: ["validityEndDate"],
+ })
+ }
})
type UpdateContractFormData = z.infer<typeof updateContractSchema>
@@ -219,15 +246,14 @@ export function GeneralContractUpdateSheet({
'AL': '연간운송계약',
'OS': '외주용역계약',
'OW': '도급계약',
- 'IS': '검사계약',
'LO': 'LOI',
'FA': 'FA',
'SC': '납품합의계약',
'OF': '클레임상계계약',
'AW': '사전작업합의',
'AD': '사전납품합의',
- 'AM': '설계계약',
- 'SC_SELL': '폐기물매각계약'
+ 'SG': '임치(물품보관)계약',
+ 'SR': '폐기물매각계약'
}
return (
<SelectItem key={type} value={type}>
@@ -293,7 +319,10 @@ export function GeneralContractUpdateSheet({
name="startDate"
render={({ field }) => (
<FormItem>
- <FormLabel>계약시작일 *</FormLabel>
+ <FormLabel>
+ 계약시작일
+ {!['AD', 'LO', 'OF'].includes(form.watch('type')) && <span className="text-red-600 ml-1">*</span>}
+ </FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
@@ -308,7 +337,10 @@ export function GeneralContractUpdateSheet({
name="endDate"
render={({ field }) => (
<FormItem>
- <FormLabel>계약종료일 *</FormLabel>
+ <FormLabel>
+ 계약종료일
+ {!['AD', 'LO', 'OF'].includes(form.watch('type')) && <span className="text-red-600 ml-1">*</span>}
+ </FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
@@ -323,7 +355,10 @@ export function GeneralContractUpdateSheet({
name="validityEndDate"
render={({ field }) => (
<FormItem>
- <FormLabel>유효기간종료일 *</FormLabel>
+ <FormLabel>
+ 유효기간종료일
+ {form.watch('type') === 'LO' && <span className="text-red-600 ml-1">*</span>}
+ </FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
diff --git a/lib/general-contracts/main/general-contracts-table-columns.tsx b/lib/general-contracts/main/general-contracts-table-columns.tsx
index a08d8b81..932446d2 100644
--- a/lib/general-contracts/main/general-contracts-table-columns.tsx
+++ b/lib/general-contracts/main/general-contracts-table-columns.tsx
@@ -48,9 +48,6 @@ export interface GeneralContractListItem {
vendorId?: number
vendorName?: string
vendorCode?: string
- projectId?: number
- projectName?: string
- projectCode?: string
managerName?: string
lastUpdatedByName?: string
}
@@ -64,9 +61,6 @@ const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'Draft':
return 'outline'
- case 'Request to Review':
- case 'Confirm to Review':
- return 'secondary'
case 'Contract Accept Request':
return 'default'
case 'Complete the Contract':
@@ -84,10 +78,6 @@ const getStatusText = (status: string) => {
switch (status) {
case 'Draft':
return '임시저장'
- case 'Request to Review':
- return '조건검토요청'
- case 'Confirm to Review':
- return '조건검토완료'
case 'Contract Accept Request':
return '계약승인요청'
case 'Complete the Contract':
@@ -138,8 +128,6 @@ const getTypeText = (type: string) => {
return '외주용역계약'
case 'OW':
return '도급계약'
- case 'IS':
- return '검사계약'
case 'LO':
return 'LOI'
case 'FA':
@@ -152,9 +140,9 @@ const getTypeText = (type: string) => {
return '사전작업합의'
case 'AD':
return '사전납품합의'
- case 'AM':
- return '설계계약'
- case 'SC_SELL':
+ case 'SG':
+ return '임치(물품보관)계약'
+ case 'SR':
return '폐기물매각계약'
default:
return type
@@ -360,22 +348,6 @@ export function getGeneralContractsColumns({ setRowAction }: GetColumnsProps): C
size: 150,
meta: { excelHeader: "협력업체명" },
},
-
- {
- accessorKey: "projectName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="프로젝트명" />,
- cell: ({ row }) => (
- <div className="flex flex-col">
- <span className="font-medium">{row.original.projectName || '-'}</span>
- <span className="text-xs text-muted-foreground">
- {row.original.projectCode ? row.original.projectCode : "-"}
- </span>
- </div>
- ),
- size: 150,
- meta: { excelHeader: "프로젝트명" },
- },
-
]
},
diff --git a/lib/general-contracts/main/general-contracts-table.tsx b/lib/general-contracts/main/general-contracts-table.tsx
index e4c96ee3..503527b3 100644
--- a/lib/general-contracts/main/general-contracts-table.tsx
+++ b/lib/general-contracts/main/general-contracts-table.tsx
@@ -49,15 +49,14 @@ const contractTypeLabels = {
'AL': '연간운송계약',
'OS': '외주용역계약',
'OW': '도급계약',
- 'IS': '검사계약',
'LO': 'LOI',
'FA': 'FA',
'SC': '납품합의계약',
'OF': '클레임상계계약',
'AW': '사전작업합의',
'AD': '사전납품합의',
- 'AM': '설계계약',
- 'SC_SELL': '폐기물매각계약'
+ 'SG': '임치(물품보관)계약',
+ 'SR': '폐기물매각계약'
}
interface GeneralContractsTableProps {
diff --git a/lib/general-contracts/service.ts b/lib/general-contracts/service.ts
index 2422706a..77593f29 100644
--- a/lib/general-contracts/service.ts
+++ b/lib/general-contracts/service.ts
@@ -9,7 +9,7 @@ import { generalContracts, generalContractItems, generalContractAttachments } fr
import { contracts, contractItems, contractEnvelopes, contractSigners } from '@/db/schema/contract'
import { basicContract, basicContractTemplates } from '@/db/schema/basicContractDocumnet'
import { vendors } from '@/db/schema/vendors'
-import { users } from '@/db/schema/users'
+import { users, roles, userRoles } from '@/db/schema/users'
import { projects } from '@/db/schema/projects'
import { items } from '@/db/schema/items'
import { filterColumns } from '@/lib/filter-columns'
@@ -225,10 +225,6 @@ export async function getGeneralContracts(input: GetGeneralContractsSchema) {
vendorId: generalContracts.vendorId,
vendorName: vendors.vendorName,
vendorCode: vendors.vendorCode,
- // Project info
- projectId: generalContracts.projectId,
- projectName: projects.name,
- projectCode: projects.code,
// User info
managerName: users.name,
lastUpdatedByName: users.name,
@@ -236,7 +232,6 @@ export async function getGeneralContracts(input: GetGeneralContractsSchema) {
.from(generalContracts)
.leftJoin(vendors, eq(generalContracts.vendorId, vendors.id))
.leftJoin(users, eq(generalContracts.registeredById, users.id))
- .leftJoin(projects, eq(generalContracts.projectId, projects.id))
.where(finalWhere)
.orderBy(...orderByColumns)
.limit(input.perPage)
@@ -287,13 +282,9 @@ export async function getContractById(id: number) {
.from(vendors)
.where(eq(vendors.id, contract[0].vendorId))
.limit(1)
-
- // Get project info
- const project = contract[0].projectId ? await db
- .select()
- .from(projects)
- .where(eq(projects.id, contract[0].projectId))
- .limit(1) : null
+
+ // vendor의 country 정보 가져오기 (없으면 기본값 'KR')
+ const vendorCountry = vendor[0]?.country || 'KR'
// Get manager info
const manager = await db
@@ -309,9 +300,7 @@ export async function getContractById(id: number) {
vendor: vendor[0] || null,
vendorCode: vendor[0]?.vendorCode || null,
vendorName: vendor[0]?.vendorName || null,
- project: project ? project[0] : null,
- projectName: project ? project[0].name : null,
- projectCode: project ? project[0].code : null,
+ vendorCountry: vendorCountry,
manager: manager[0] || null
}
} catch (error) {
@@ -392,7 +381,6 @@ export async function createContract(data: Record<string, unknown>) {
executionMethod: data.executionMethod as string,
name: data.name as string,
vendorId: data.vendorId as number,
- projectId: data.projectId as number,
startDate: data.startDate as string,
endDate: data.endDate as string,
validityEndDate: data.validityEndDate as string,
@@ -424,10 +412,6 @@ export async function createContract(data: Record<string, unknown>) {
contractTerminationConditions: data.contractTerminationConditions || {},
terms: data.terms || {},
complianceChecklist: data.complianceChecklist || {},
- communicationChannels: data.communicationChannels || {},
- locations: data.locations || {},
- fieldServiceRates: data.fieldServiceRates || {},
- offsetDetails: data.offsetDetails || {},
totalAmount: data.totalAmount as number,
availableBudget: data.availableBudget as number,
registeredById: data.registeredById as number,
@@ -451,6 +435,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
// 업데이트할 데이터 정리
// 클라이언트에서 전송된 formData를 그대로 사용합니다.
const {
+ contractScope,
specificationType,
specificationManualText,
unitPriceType,
@@ -475,6 +460,8 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
interlockingSystem,
mandatoryDocuments,
contractTerminationConditions,
+ externalYardEntry,
+ contractAmountReason,
} = data
// 계약금액 자동 집계 로직
@@ -507,6 +494,7 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
// 업데이트할 데이터 객체 생성
const updateData: Record<string, unknown> = {
+ contractScope,
specificationType,
specificationManualText,
unitPriceType,
@@ -531,6 +519,8 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
interlockingSystem,
mandatoryDocuments, // JSON 필드
contractTerminationConditions, // JSON 필드
+ externalYardEntry,
+ contractAmountReason: convertEmptyStringToNull(contractAmountReason),
contractAmount: calculatedContractAmount || 0,
lastUpdatedAt: new Date(),
lastUpdatedById: userId,
@@ -543,6 +533,12 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
.where(eq(generalContracts.id, id))
.returning()
+ // 계약명 I/F 로직 (39번 화면으로의 I/F)
+ // TODO: 39번 화면의 정확한 API 엔드포인트나 함수명 확인 필요
+ // if (data.name) {
+ // await syncContractNameToScreen39(id, data.name as string)
+ // }
+
revalidatePath('/general-contracts')
revalidatePath(`/general-contracts/detail/${id}`)
return updatedContract
@@ -556,8 +552,28 @@ export async function updateContractBasicInfo(id: number, data: Record<string, u
export async function getContractItems(contractId: number) {
try {
const items = await db
- .select()
+ .select({
+ id: generalContractItems.id,
+ contractId: generalContractItems.contractId,
+ projectId: generalContractItems.projectId,
+ itemCode: generalContractItems.itemCode,
+ itemInfo: generalContractItems.itemInfo,
+ specification: generalContractItems.specification,
+ quantity: generalContractItems.quantity,
+ quantityUnit: generalContractItems.quantityUnit,
+ totalWeight: generalContractItems.totalWeight,
+ weightUnit: generalContractItems.weightUnit,
+ contractDeliveryDate: generalContractItems.contractDeliveryDate,
+ contractUnitPrice: generalContractItems.contractUnitPrice,
+ contractAmount: generalContractItems.contractAmount,
+ contractCurrency: generalContractItems.contractCurrency,
+ createdAt: generalContractItems.createdAt,
+ updatedAt: generalContractItems.updatedAt,
+ projectName: projects.name,
+ projectCode: projects.code,
+ })
.from(generalContractItems)
+ .leftJoin(projects, eq(generalContractItems.projectId, projects.id))
.where(eq(generalContractItems.contractId, contractId))
.orderBy(asc(generalContractItems.id))
@@ -575,6 +591,7 @@ export async function createContractItem(contractId: number, itemData: Record<st
.insert(generalContractItems)
.values({
contractId,
+ projectId: itemData.projectId ? (itemData.projectId as number) : null,
itemCode: itemData.itemCode as string,
itemInfo: itemData.itemInfo as string,
specification: itemData.specification as string,
@@ -604,6 +621,7 @@ export async function updateContractItem(itemId: number, itemData: Record<string
const [updatedItem] = await db
.update(generalContractItems)
.set({
+ projectId: itemData.projectId ? (itemData.projectId as number) : null,
itemCode: itemData.itemCode as string,
itemInfo: itemData.itemInfo as string,
specification: itemData.specification as string,
@@ -673,6 +691,7 @@ export async function updateContractItems(contractId: number, items: Record<stri
.values(
items.map((item: Record<string, unknown>) => ({
contractId,
+ projectId: item.projectId ? (item.projectId as number) : null,
itemCode: item.itemCode as string,
itemInfo: item.itemInfo as string,
specification: item.specification as string,
@@ -829,7 +848,8 @@ export async function getBasicInfo(contractId: number) {
contractEstablishmentConditions: contract.contractEstablishmentConditions,
interlockingSystem: contract.interlockingSystem,
mandatoryDocuments: contract.mandatoryDocuments,
- contractTerminationConditions: contract.contractTerminationConditions
+ contractTerminationConditions: contract.contractTerminationConditions,
+ externalYardEntry: contract.externalYardEntry || 'N'
}
}
} catch (error) {
@@ -838,87 +858,6 @@ export async function getBasicInfo(contractId: number) {
}
}
-
-export async function getCommunicationChannel(contractId: number) {
- try {
- const [contract] = await db
- .select({
- communicationChannels: generalContracts.communicationChannels
- })
- .from(generalContracts)
- .where(eq(generalContracts.id, contractId))
- .limit(1)
-
- if (!contract) {
- return null
- }
-
- return contract.communicationChannels as any
- } catch (error) {
- console.error('Error getting communication channel:', error)
- throw new Error('Failed to get communication channel')
- }
-}
-
-export async function updateCommunicationChannel(contractId: number, communicationData: Record<string, unknown>, userId: number) {
- try {
- await db
- .update(generalContracts)
- .set({
- communicationChannels: communicationData,
- lastUpdatedAt: new Date(),
- lastUpdatedById: userId
- })
- .where(eq(generalContracts.id, contractId))
-
- revalidatePath('/general-contracts')
- return { success: true }
- } catch (error) {
- console.error('Error updating communication channel:', error)
- throw new Error('Failed to update communication channel')
- }
-}
-
-export async function updateLocation(contractId: number, locationData: Record<string, unknown>, userId: number) {
- try {
- await db
- .update(generalContracts)
- .set({
- locations: locationData,
- lastUpdatedAt: new Date(),
- lastUpdatedById: userId
- })
- .where(eq(generalContracts.id, contractId))
-
- revalidatePath('/general-contracts')
- return { success: true }
- } catch (error) {
- console.error('Error updating location:', error)
- throw new Error('Failed to update location')
- }
-}
-
-export async function getLocation(contractId: number) {
- try {
- const [contract] = await db
- .select({
- locations: generalContracts.locations
- })
- .from(generalContracts)
- .where(eq(generalContracts.id, contractId))
- .limit(1)
-
- if (!contract) {
- return null
- }
-
- return contract.locations as any
- } catch (error) {
- console.error('Error getting location:', error)
- throw new Error('Failed to get location')
- }
-}
-
export async function updateContract(id: number, data: Record<string, unknown>) {
try {
// 숫자 필드에서 빈 문자열을 null로 변환
@@ -990,7 +929,7 @@ export async function updateContract(id: number, data: Record<string, unknown>)
.insert(generalContractItems)
.values(
data.contractItems.map((item: any) => ({
- project: item.project,
+ projectId: item.projectId ? (item.projectId as number) : null,
itemCode: item.itemCode,
itemInfo: item.itemInfo,
specification: item.specification,
@@ -1452,6 +1391,49 @@ export async function sendContractApprovalRequest(
signerStatus: 'PENDING',
})
+ // 사외업체 야드투입이 'Y'인 경우 안전담당자 자동 지정
+ if (contractSummary.basicInfo?.externalYardEntry === 'Y') {
+ try {
+ // 안전담당자 역할을 가진 사용자 조회 (역할명에 '안전' 또는 'safety' 포함)
+ const safetyManagers = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ })
+ .from(users)
+ .innerJoin(userRoles, eq(users.id, userRoles.userId))
+ .innerJoin(roles, eq(userRoles.roleId, roles.id))
+ .where(
+ and(
+ or(
+ like(roles.name, '%안전%'),
+ like(roles.name, '%safety%'),
+ like(roles.name, '%Safety%')
+ ),
+ eq(users.isActive, true)
+ )
+ )
+ .limit(1)
+
+ // 첫 번째 안전담당자를 자동 추가
+ if (safetyManagers.length > 0) {
+ const safetyManager = safetyManagers[0]
+ await db.insert(contractSigners).values({
+ envelopeId: newEnvelope.id,
+ signerType: 'SAFETY_MANAGER',
+ signerEmail: safetyManager.email || '',
+ signerName: safetyManager.name || '안전담당자',
+ signerPosition: '안전담당자',
+ signerStatus: 'PENDING',
+ })
+ }
+ } catch (error) {
+ console.error('Error adding safety manager:', error)
+ // 안전담당자 추가 실패해도 계약 승인 요청은 계속 진행
+ }
+ }
+
// generalContractAttachments에 contractId 업데이트 (일반계약의 첨부파일들을 PO 계약과 연결)
const generalContractId = contractSummary.basicInfo?.id || contractSummary.id
if (generalContractId) {
@@ -1705,93 +1687,142 @@ async function mapContractSummaryToDb(contractSummary: any) {
}
}
-// Field Service Rate 관련 서버 액션들
-export async function getFieldServiceRate(contractId: number) {
+
+// 계약번호 생성 함수
+// 임치계약 정보 조회
+export async function getStorageInfo(contractId: number) {
try {
- const result = await db
- .select({ fieldServiceRates: generalContracts.fieldServiceRates })
+ const contract = await db
+ .select({ terms: generalContracts.terms })
.from(generalContracts)
.where(eq(generalContracts.id, contractId))
.limit(1)
- if (result.length === 0) {
- return null
+ if (!contract.length || !contract[0].terms) {
+ return []
}
- return result[0].fieldServiceRates as Record<string, unknown> || null
+ const terms = contract[0].terms as any
+ return terms.storageInfo || []
} catch (error) {
- console.error('Failed to get field service rate:', error)
- throw new Error('Field Service Rate 데이터를 불러오는데 실패했습니다.')
+ console.error('Error getting storage info:', error)
+ throw new Error('Failed to get storage info')
}
}
-export async function updateFieldServiceRate(
- contractId: number,
- fieldServiceRateData: Record<string, unknown>,
- userId: number
-) {
+// 임치계약 정보 저장
+export async function saveStorageInfo(contractId: number, items: Array<{ poNumber: string; hullNumber: string; remainingAmount: number }>, userId: number) {
try {
+ const contract = await db
+ .select({ terms: generalContracts.terms })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract.length) {
+ throw new Error('Contract not found')
+ }
+
+ const currentTerms = (contract[0].terms || {}) as any
+ const updatedTerms = {
+ ...currentTerms,
+ storageInfo: items
+ }
+
await db
.update(generalContracts)
.set({
- fieldServiceRates: fieldServiceRateData,
+ terms: updatedTerms,
lastUpdatedAt: new Date(),
- lastUpdatedById: userId
+ lastUpdatedById: userId,
})
.where(eq(generalContracts.id, contractId))
- revalidatePath('/evcp/general-contracts')
- return { success: true }
+ revalidatePath(`/general-contracts/detail/${contractId}`)
} catch (error) {
- console.error('Failed to update field service rate:', error)
- throw new Error('Field Service Rate 업데이트에 실패했습니다.')
+ console.error('Error saving storage info:', error)
+ throw new Error('Failed to save storage info')
}
}
-// Offset Details 관련 서버 액션들
-export async function getOffsetDetails(contractId: number) {
+// 야드투입 정보 조회
+export async function getYardEntryInfo(contractId: number) {
try {
- const result = await db
- .select({ offsetDetails: generalContracts.offsetDetails })
+ const contract = await db
+ .select({ terms: generalContracts.terms })
.from(generalContracts)
.where(eq(generalContracts.id, contractId))
.limit(1)
- if (result.length === 0) {
+ if (!contract.length || !contract[0].terms) {
return null
}
- return result[0].offsetDetails as Record<string, unknown> || null
+ const terms = contract[0].terms as any
+ return terms.yardEntryInfo || null
} catch (error) {
- console.error('Failed to get offset details:', error)
- throw new Error('회입/상계내역 데이터를 불러오는데 실패했습니다.')
+ console.error('Error getting yard entry info:', error)
+ throw new Error('Failed to get yard entry info')
}
}
-export async function updateOffsetDetails(
- contractId: number,
- offsetDetailsData: Record<string, unknown>,
- userId: number
-) {
+// 야드투입 정보 저장
+export async function saveYardEntryInfo(contractId: number, data: { projectId: number | null; projectCode: string; projectName: string; managerName: string; managerDepartment: string; rehandlingContractor: string }, userId: number) {
try {
+ const contract = await db
+ .select({ terms: generalContracts.terms })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract.length) {
+ throw new Error('Contract not found')
+ }
+
+ const currentTerms = (contract[0].terms || {}) as any
+ const updatedTerms = {
+ ...currentTerms,
+ yardEntryInfo: data
+ }
+
await db
.update(generalContracts)
.set({
- offsetDetails: offsetDetailsData,
+ terms: updatedTerms,
lastUpdatedAt: new Date(),
- lastUpdatedById: userId
+ lastUpdatedById: userId,
})
.where(eq(generalContracts.id, contractId))
- revalidatePath('/evcp/general-contracts')
- return { success: true }
+ revalidatePath(`/general-contracts/detail/${contractId}`)
} catch (error) {
- console.error('Failed to update offset details:', error)
- throw new Error('회입/상계내역 업데이트에 실패했습니다.')
+ console.error('Error saving yard entry info:', error)
+ throw new Error('Failed to save yard entry info')
+ }
+}
+
+// 계약 문서 댓글 저장
+export async function saveContractAttachmentComment(attachmentId: number, contractId: number, commentType: 'shi' | 'vendor', comment: string, userId: number) {
+ try {
+ const updateData: Record<string, unknown> = {}
+ if (commentType === 'shi') {
+ updateData.shiComment = comment
+ } else {
+ updateData.vendorComment = comment
+ }
+
+ await db
+ .update(generalContractAttachments)
+ .set(updateData)
+ .where(eq(generalContractAttachments.id, attachmentId))
+
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+ } catch (error) {
+ console.error('Error saving attachment comment:', error)
+ throw new Error('Failed to save attachment comment')
}
}
-// 계약번호 생성 함수
export async function generateContractNumber(
userId?: string,
contractType: string
@@ -1805,15 +1836,14 @@ export async function generateContractNumber(
'AL': 'AL', // 연간운송계약
'OS': 'OS', // 외주용역계약
'OW': 'OW', // 도급계약
- 'IS': 'IS', // 검사계약
'LO': 'LO', // LOI (의향서)
'FA': 'FA', // FA (Frame Agreement)
'SC': 'SC', // 납품합의계약 (Supply Contract)
'OF': 'OF', // 클레임상계계약 (Offset Agreement)
'AW': 'AW', // 사전작업합의 (Advanced Work)
'AD': 'AD', // 사전납품합의 (Advanced Delivery)
- 'AM': 'AM', // 설계계약
- 'SC_SELL': 'SC' // 폐기물매각계약 (Scrap) - 납품합의계약과 동일한 코드 사용
+ 'SG': 'SG', // 임치(물품보관)계약
+ 'SR': 'SR' // 폐기물매각계약 (Scrap)
}
const typeCode = contractTypeMap[contractType] || 'XX' // 기본값
@@ -1912,7 +1942,7 @@ export async function generateContractNumber(
}
}
-// 프로젝트 목록 조회
+// 프로젝트 목록 조회 (코드와 이름만 반환)
export async function getProjects() {
try {
const projectList = await db
@@ -1920,14 +1950,782 @@ export async function getProjects() {
id: projects.id,
code: projects.code,
name: projects.name,
- type: projects.type,
})
.from(projects)
.orderBy(asc(projects.name))
return projectList
} catch (error) {
- console.error('Error fetching projects:', error)
- throw new Error('Failed to fetch projects')
+ console.error('프로젝트 목록 조회 오류:', error)
+ throw new Error('프로젝트 목록을 불러오는데 실패했습니다')
}
}
+
+// ═══════════════════════════════════════════════════════════════
+// 협력업체 전용 조건검토 조회 함수
+// ═══════════════════════════════════════════════════════════════
+
+// 협력업체 전용 조건검토 계약 조회
+export async function getVendorContractReviews(
+ vendorId: number,
+ page: number = 1,
+ perPage: number = 10,
+ search?: string
+) {
+ try {
+ const offset = (page - 1) * perPage
+
+ // 조건검토 관련 상태들
+ const reviewStatuses = ['Request to Review', 'Vendor Replied Review', 'SHI Confirmed Review']
+
+ // 기본 조건: vendorId와 status 필터
+ const conditions: SQL<unknown>[] = [
+ eq(generalContracts.vendorId, vendorId),
+ or(...reviewStatuses.map(status => eq(generalContracts.status, status)))!
+ ]
+
+ // 검색 조건 추가
+ if (search) {
+ const searchPattern = `%${search}%`
+ conditions.push(
+ or(
+ ilike(generalContracts.contractNumber, searchPattern),
+ ilike(generalContracts.name, searchPattern),
+ ilike(generalContracts.notes, searchPattern)
+ )!
+ )
+ }
+
+ const whereCondition = and(...conditions)
+
+ // 전체 개수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(generalContracts)
+ .where(whereCondition)
+
+ const total = totalResult[0]?.count || 0
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 }
+ }
+
+ // 데이터 조회
+ const data = await db
+ .select({
+ id: generalContracts.id,
+ contractNumber: generalContracts.contractNumber,
+ revision: generalContracts.revision,
+ status: generalContracts.status,
+ category: generalContracts.category,
+ type: generalContracts.type,
+ executionMethod: generalContracts.executionMethod,
+ name: generalContracts.name,
+ contractSourceType: generalContracts.contractSourceType,
+ startDate: generalContracts.startDate,
+ endDate: generalContracts.endDate,
+ validityEndDate: generalContracts.validityEndDate,
+ contractScope: generalContracts.contractScope,
+ specificationType: generalContracts.specificationType,
+ specificationManualText: generalContracts.specificationManualText,
+ contractAmount: generalContracts.contractAmount,
+ totalAmount: generalContracts.totalAmount,
+ currency: generalContracts.currency,
+ registeredAt: generalContracts.registeredAt,
+ signedAt: generalContracts.signedAt,
+ linkedRfqOrItb: generalContracts.linkedRfqOrItb,
+ linkedPoNumber: generalContracts.linkedPoNumber,
+ linkedBidNumber: generalContracts.linkedBidNumber,
+ lastUpdatedAt: generalContracts.lastUpdatedAt,
+ notes: generalContracts.notes,
+ vendorId: generalContracts.vendorId,
+ registeredById: generalContracts.registeredById,
+ lastUpdatedById: generalContracts.lastUpdatedById,
+ vendorName: vendors.vendorName,
+ vendorCode: vendors.vendorCode,
+ managerName: users.name,
+ })
+ .from(generalContracts)
+ .leftJoin(vendors, eq(generalContracts.vendorId, vendors.id))
+ .leftJoin(users, eq(generalContracts.registeredById, users.id))
+ .where(whereCondition)
+ .orderBy(desc(generalContracts.registeredAt))
+ .limit(perPage)
+ .offset(offset)
+
+ const pageCount = Math.ceil(total / perPage)
+
+ // 날짜 변환 헬퍼 함수
+ const formatDate = (date: unknown): string => {
+ if (!date) return ''
+ if (date instanceof Date) {
+ return date.toISOString()
+ }
+ if (typeof date === 'string') {
+ return date
+ }
+ return String(date)
+ }
+
+ return {
+ data: data.map((row) => ({
+ id: row.id,
+ contractNumber: row.contractNumber || '',
+ revision: row.revision || 0,
+ status: row.status || '',
+ category: row.category || '',
+ type: row.type || '',
+ executionMethod: row.executionMethod || '',
+ name: row.name || '',
+ contractSourceType: row.contractSourceType || '',
+ startDate: formatDate(row.startDate),
+ endDate: formatDate(row.endDate),
+ validityEndDate: formatDate(row.validityEndDate),
+ contractScope: row.contractScope || '',
+ specificationType: row.specificationType || '',
+ specificationManualText: row.specificationManualText || '',
+ contractAmount: row.contractAmount ? row.contractAmount.toString() : '',
+ totalAmount: row.totalAmount ? row.totalAmount.toString() : '',
+ currency: row.currency || '',
+ registeredAt: formatDate(row.registeredAt),
+ signedAt: formatDate(row.signedAt),
+ linkedRfqOrItb: row.linkedRfqOrItb || '',
+ linkedPoNumber: row.linkedPoNumber || '',
+ linkedBidNumber: row.linkedBidNumber || '',
+ lastUpdatedAt: formatDate(row.lastUpdatedAt),
+ notes: row.notes || '',
+ vendorId: row.vendorId || 0,
+ registeredById: row.registeredById || 0,
+ lastUpdatedById: row.lastUpdatedById || 0,
+ vendorName: row.vendorName || '',
+ vendorCode: row.vendorCode || '',
+ managerName: row.managerName || '',
+ })),
+ pageCount,
+ total,
+ }
+ console.log(data, "data")
+ } catch (error) {
+ console.error('Error fetching vendor contract reviews:', error)
+ return { data: [], pageCount: 0, total: 0 }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// 조건검토 의견 관련 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// 협력업체 조건검토 의견 저장
+export async function saveVendorComment(
+ contractId: number,
+ vendorComment: string,
+ vendorId: number
+) {
+ try {
+ // 계약 정보 조회 및 권한 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ if (contract.vendorId !== vendorId) {
+ throw new Error('이 계약에 대한 접근 권한이 없습니다.')
+ }
+
+ // generalContracts 테이블에 vendorComment 저장
+ await db
+ .update(generalContracts)
+ .set({
+ vendorComment: vendorComment,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: contract.lastUpdatedById, // 기존 수정자 유지
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath(`/partners/general-contract-review/${contractId}`)
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '협력업체 의견이 저장되었습니다.' }
+ } catch (error) {
+ console.error('협력업체 의견 저장 오류:', error)
+ throw error
+ }
+}
+
+// 협력업체 조건검토 의견 조회
+export async function getVendorComment(contractId: number, vendorId?: number) {
+ try {
+ const conditions = [eq(generalContracts.id, contractId)]
+
+ if (vendorId) {
+ conditions.push(eq(generalContracts.vendorId, vendorId))
+ }
+
+ const [contract] = await db
+ .select({
+ vendorComment: generalContracts.vendorComment,
+ })
+ .from(generalContracts)
+ .where(and(...conditions))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ return {
+ success: true,
+ vendorComment: contract.vendorComment || '',
+ }
+ } catch (error) {
+ console.error('협력업체 의견 조회 오류:', error)
+ throw error
+ }
+}
+
+// 당사 조건검토 의견 저장
+export async function saveShiComment(
+ contractId: number,
+ shiComment: string,
+ userId: number
+) {
+ try {
+ // 계약 정보 조회
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // generalContracts 테이블에 shiComment 저장
+ await db
+ .update(generalContracts)
+ .set({
+ shiComment: shiComment,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId,
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+ revalidatePath(`/partners/general-contract-review/${contractId}`)
+
+ return { success: true, message: '당사 의견이 저장되었습니다.' }
+ } catch (error) {
+ console.error('당사 의견 저장 오류:', error)
+ throw error
+ }
+}
+
+// 당사 조건검토 의견 조회
+export async function getShiComment(contractId: number) {
+ try {
+ const [contract] = await db
+ .select({
+ shiComment: generalContracts.shiComment,
+ })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ return {
+ success: true,
+ shiComment: contract.shiComment || '',
+ }
+ } catch (error) {
+ console.error('당사 의견 조회 오류:', error)
+ throw error
+ }
+}
+
+// 조건검토 의견 모두 조회 (vendorComment + shiComment)
+export async function getContractReviewComments(contractId: number, vendorId?: number) {
+ try {
+ const conditions = [eq(generalContracts.id, contractId)]
+
+ if (vendorId) {
+ conditions.push(eq(generalContracts.vendorId, vendorId))
+ }
+
+ const [contract] = await db
+ .select({
+ vendorComment: generalContracts.vendorComment,
+ shiComment: generalContracts.shiComment,
+ })
+ .from(generalContracts)
+ .where(and(...conditions))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ return {
+ success: true,
+ vendorComment: contract.vendorComment || '',
+ shiComment: contract.shiComment || '',
+ }
+ } catch (error) {
+ console.error('조건검토 의견 조회 오류:', error)
+ throw error
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// 조건검토요청 관련 함수들
+// ═══════════════════════════════════════════════════════════════
+
+// 조건검토용 파일 업로드
+export async function uploadContractReviewFile(contractId: number, file: File, userId: string) {
+ try {
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `general-contracts/${contractId}/review-documents`,
+ userId,
+ )
+
+ if (saveResult.success && saveResult.publicPath) {
+ return {
+ success: true,
+ message: '파일이 성공적으로 업로드되었습니다.',
+ filePath: saveResult.publicPath,
+ fileName: saveResult.fileName || file.name
+ }
+ } else {
+ return {
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다.'
+ }
+ }
+ } catch (error) {
+ console.error('Failed to upload contract review file:', error)
+ return {
+ success: false,
+ error: '파일 업로드에 실패했습니다.'
+ }
+ }
+}
+
+// 조건검토요청 전송 (PDF 포함)
+export async function sendContractReviewRequest(
+ contractSummary: any,
+ pdfBuffer: Uint8Array,
+ contractId: number,
+ userId: string
+) {
+ try {
+ const userIdNumber = parseInt(userId)
+ if (isNaN(userIdNumber)) {
+ throw new Error('Invalid user ID')
+ }
+
+ // 계약 정보 조회
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // PDF 버퍼를 saveBuffer 함수로 저장
+ const fileId = uuidv4()
+ const fileName = `contract_review_${fileId}.pdf`
+
+ // PDF 버퍼를 Buffer로 변환
+ let bufferData: Buffer
+ if (Buffer.isBuffer(pdfBuffer)) {
+ bufferData = pdfBuffer
+ } else if (pdfBuffer instanceof ArrayBuffer) {
+ bufferData = Buffer.from(pdfBuffer)
+ } else if (pdfBuffer instanceof Uint8Array) {
+ bufferData = Buffer.from(pdfBuffer)
+ } else {
+ bufferData = Buffer.from(pdfBuffer as any)
+ }
+
+ // saveBuffer 함수를 사용해서 파일 저장
+ const saveResult = await saveBuffer({
+ buffer: bufferData,
+ fileName: fileName,
+ directory: "generalContracts",
+ originalName: `contract_review_${contractId}_${fileId}.pdf`,
+ userId: userId
+ })
+
+ if (!saveResult.success) {
+ throw new Error(saveResult.error || 'PDF 파일 저장에 실패했습니다.')
+ }
+
+ const finalFileName = saveResult.fileName || fileName
+ const finalFilePath = saveResult.publicPath
+ ? saveResult.publicPath.replace('/api/files/', '')
+ : `/generalContracts/${fileName}`
+
+ // generalContractAttachments 테이블에 계약서 초안 PDF 저장
+ await db.insert(generalContractAttachments).values({
+ contractId: contractId,
+ documentName: '계약서 초안',
+ fileName: finalFileName,
+ filePath: finalFilePath,
+ uploadedById: userIdNumber,
+ uploadedAt: new Date(),
+ })
+
+ // 계약 상태를 'Request to Review'로 변경
+ await db
+ .update(generalContracts)
+ .set({
+ status: 'Request to Review',
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userIdNumber,
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ // 협력업체 정보 조회
+ const [vendor] = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, contract.vendorId))
+ .limit(1)
+
+ // 협력업체 담당자에게 검토 요청 이메일 발송
+ if (vendor?.vendorEmail) {
+ try {
+ await sendEmail({
+ to: vendor.vendorEmail,
+ subject: `[SHI] 일반계약 조건검토 요청 - ${contract.contractNumber}`,
+ template: 'contract-review-request',
+ context: {
+ contractId: contractId,
+ contractNumber: contract.contractNumber,
+ contractName: contract.name,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/general-contract-review/${contractId}`,
+ language: 'ko',
+ },
+ })
+ } catch (emailError) {
+ console.error('이메일 발송 실패:', emailError)
+ // 이메일 발송 실패해도 계약 상태 변경은 유지
+ }
+ }
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '조건검토요청이 성공적으로 전송되었습니다.' }
+ } catch (error: any) {
+ console.error('조건검토요청 전송 오류:', error)
+ return {
+ success: false,
+ error: error.message || '조건검토요청 전송에 실패했습니다.'
+ }
+ }
+}
+
+// 조건검토요청 전송 (기존 함수 - 하위 호환성 유지)
+export async function requestContractReview(contractId: number, userId: number) {
+ try {
+ // 계약 정보 조회
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // 계약 상태를 'Request to Review'로 변경
+ await db
+ .update(generalContracts)
+ .set({
+ status: 'Request to Review',
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId,
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ // 협력업체 정보 조회
+ const [vendor] = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, contract.vendorId))
+ .limit(1)
+
+ // 협력업체 담당자에게 검토 요청 이메일 발송
+ if (vendor?.vendorEmail) {
+ try {
+ await sendEmail({
+ to: vendor.vendorEmail,
+ subject: `[SHI] 일반계약 조건검토 요청 - ${contract.contractNumber}`,
+ template: 'contract-review-request',
+ context: {
+ contractId: contractId,
+ contractNumber: contract.contractNumber,
+ contractName: contract.name,
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/general-contract-review/${contractId}`,
+ language: 'ko',
+ },
+ })
+ } catch (emailError) {
+ console.error('이메일 발송 실패:', emailError)
+ // 이메일 발송 실패해도 계약 상태 변경은 유지
+ }
+ }
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '조건검토요청이 성공적으로 전송되었습니다.' }
+ } catch (error) {
+ console.error('조건검토요청 전송 오류:', error)
+ throw new Error('조건검토요청 전송에 실패했습니다.')
+ }
+}
+
+// 협력업체용 계약 정보 조회 (검토용 최소 정보)
+export async function getContractForVendorReview(contractId: number, vendorId?: number) {
+ try {
+ const contract = await db
+ .select({
+ id: generalContracts.id,
+ contractNumber: generalContracts.contractNumber,
+ revision: generalContracts.revision,
+ name: generalContracts.name,
+ status: generalContracts.status,
+ type: generalContracts.type,
+ category: generalContracts.category,
+ vendorId: generalContracts.vendorId,
+ contractAmount: generalContracts.contractAmount,
+ currency: generalContracts.currency,
+ startDate: generalContracts.startDate,
+ endDate: generalContracts.endDate,
+ specificationType: generalContracts.specificationType,
+ specificationManualText: generalContracts.specificationManualText,
+ contractScope: generalContracts.contractScope,
+ notes: generalContracts.notes,
+ vendorComment: generalContracts.vendorComment,
+ shiComment: generalContracts.shiComment,
+ })
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract.length) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // 권한 확인: vendorId가 제공된 경우 해당 협력업체의 계약인지 확인
+ if (vendorId && contract[0].vendorId !== vendorId) {
+ throw new Error('이 계약에 대한 접근 권한이 없습니다.')
+ }
+
+ // 품목 정보 조회
+ const contractItems = await db
+ .select()
+ .from(generalContractItems)
+ .where(eq(generalContractItems.contractId, contractId))
+
+ // 첨부파일 조회
+ const attachments = await db
+ .select()
+ .from(generalContractAttachments)
+ .where(eq(generalContractAttachments.contractId, contractId))
+
+ // 협력업체 정보 조회
+ const [vendor] = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, contract[0].vendorId))
+ .limit(1)
+
+ return {
+ ...contract[0],
+ contractItems,
+ attachments,
+ vendor: vendor || null,
+ }
+ } catch (error) {
+ console.error('협력업체용 계약 정보 조회 오류:', error)
+ throw error
+ }
+}
+
+// 협력업체 의견 회신
+export async function vendorReplyToContractReview(
+ contractId: number,
+ vendorComment: string,
+ vendorId: number
+) {
+ try {
+ // 계약 정보 조회 및 권한 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ if (contract.vendorId !== vendorId) {
+ throw new Error('이 계약에 대한 접근 권한이 없습니다.')
+ }
+
+ // 계약 상태 확인
+ if (contract.status !== 'Request to Review') {
+ throw new Error('조건검토요청 상태가 아닙니다.')
+ }
+
+ // 계약 상태를 'Vendor Replied Review'로 변경하고 vendorComment 저장
+ await db
+ .update(generalContracts)
+ .set({
+ status: 'Vendor Replied Review',
+ vendorComment: vendorComment,
+ lastUpdatedAt: new Date(),
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ // 당사 구매 담당자에게 회신 알림 이메일 발송
+ const [manager] = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, contract.registeredById))
+ .limit(1)
+
+ if (manager?.email) {
+ try {
+ await sendEmail({
+ to: manager.email,
+ subject: `[SHI] 협력업체 조건검토 회신 - ${contract.contractNumber}`,
+ template: 'vendor-review-reply',
+ context: {
+ contractId: contractId,
+ contractNumber: contract.contractNumber,
+ contractName: contract.name,
+ vendorName: contract.vendorName || '협력업체',
+ loginUrl: `${process.env.NEXT_PUBLIC_URL}/evcp/general-contracts/detail/${contractId}`,
+ language: 'ko',
+ },
+ })
+ } catch (emailError) {
+ console.error('이메일 발송 실패:', emailError)
+ }
+ }
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '의견이 성공적으로 회신되었습니다.' }
+ } catch (error) {
+ console.error('협력업체 의견 회신 오류:', error)
+ throw error
+ }
+}
+
+// 협력업체 의견 임시 저장
+export async function saveVendorCommentDraft(
+ contractId: number,
+ vendorComment: string,
+ vendorId: number
+) {
+ try {
+ // 계약 정보 조회 및 권한 확인
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ if (contract.vendorId !== vendorId) {
+ throw new Error('이 계약에 대한 접근 권한이 없습니다.')
+ }
+
+ // 협력업체 의견을 임시 저장 (generalContracts 테이블의 vendorComment에 저장, 상태는 변경하지 않음)
+ await db
+ .update(generalContracts)
+ .set({
+ vendorComment: vendorComment,
+ lastUpdatedAt: new Date(),
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ return { success: true, message: '의견이 임시 저장되었습니다.' }
+ } catch (error) {
+ console.error('협력업체 의견 임시 저장 오류:', error)
+ throw error
+ }
+}
+
+// 당사 검토 확정
+export async function confirmContractReview(
+ contractId: number,
+ shiComment: string,
+ userId: number
+) {
+ try {
+ // 계약 정보 조회
+ const [contract] = await db
+ .select()
+ .from(generalContracts)
+ .where(eq(generalContracts.id, contractId))
+ .limit(1)
+
+ if (!contract) {
+ throw new Error('계약을 찾을 수 없습니다.')
+ }
+
+ // 계약 상태 확인
+ if (contract.status !== 'Vendor Replied Review') {
+ throw new Error('협력업체 회신 상태가 아닙니다.')
+ }
+
+ // 계약 상태를 'SHI Confirmed Review'로 변경하고 shiComment 저장
+ await db
+ .update(generalContracts)
+ .set({
+ status: 'SHI Confirmed Review',
+ shiComment: shiComment,
+ lastUpdatedAt: new Date(),
+ lastUpdatedById: userId,
+ })
+ .where(eq(generalContracts.id, contractId))
+
+ revalidatePath('/general-contracts')
+ revalidatePath(`/general-contracts/detail/${contractId}`)
+
+ return { success: true, message: '검토가 확정되었습니다.' }
+ } catch (error) {
+ console.error('당사 검토 확정 오류:', error)
+ throw error
+ }
+} \ No newline at end of file
diff --git a/lib/general-contracts/types.ts b/lib/general-contracts/types.ts
index 2b6731b6..33b1189f 100644
--- a/lib/general-contracts/types.ts
+++ b/lib/general-contracts/types.ts
@@ -17,15 +17,14 @@ export const GENERAL_CONTRACT_TYPES = [
'AL', // 연간운송계약
'OS', // 외주용역계약
'OW', // 도급계약
- 'IS', // 검사계약
'LO', // LOI (의향서)
'FA', // FA (Frame Agreement)
'SC', // 납품합의계약 (Supply Contract)
'OF', // 클레임상계계약 (Offset Agreement)
'AW', // 사전작업합의 (Advanced Work)
'AD', // 사전납품합의 (Advanced Delivery)
- 'AM', // 설계계약
- 'SC_SELL' // 폐기물매각계약 (Scrap) - 납품합의계약과 코드 중복으로 별도 명명
+ 'SG', // 임치(물품보관)계약
+ 'SR' // 폐기물매각계약 (Scrap)
] as const;
export type GeneralContractType = typeof GENERAL_CONTRACT_TYPES[number];
@@ -34,7 +33,8 @@ export type GeneralContractType = typeof GENERAL_CONTRACT_TYPES[number];
export const GENERAL_CONTRACT_STATUSES = [
'Draft', // 임시 저장
'Request to Review', // 조건검토요청
- 'Confirm to Review', // 조건검토완료
+ 'Vendor Replied Review', // 협력업체 회신
+ 'SHI Confirmed Review', // 당사 검토 확정
'Contract Accept Request', // 계약승인요청
'Complete the Contract', // 계약체결(승인)
'Reject to Accept Contract', // 계약승인거절