- LocationDetailPanel: 6개 탭 구현 (본체, 가이드레일, 케이스, 하단마감재, 모터&제어기, 부자재) - 각 탭별 다른 테이블 컬럼 구조 적용 - QuoteSummaryPanel: 개소별/상세별 합계 패널 개선 - QuotePreviewModal: EstimateDocumentModal 패턴 적용 (헤더+버튼 영역 분리) - Input value → defaultValue 변경으로 React 경고 해결 - 팩스/카카오톡 버튼 제거 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
539 lines
15 KiB
TypeScript
539 lines
15 KiB
TypeScript
'use server';
|
|
|
|
import type {
|
|
Contract,
|
|
ContractDetail,
|
|
ContractStats,
|
|
ContractStageCount,
|
|
ContractListResponse,
|
|
ContractFilter,
|
|
ContractFormData,
|
|
} from './types';
|
|
|
|
// 목업 데이터
|
|
const MOCK_CONTRACTS: Contract[] = [
|
|
{
|
|
id: '1',
|
|
contractCode: 'CT-2025-001',
|
|
partnerId: '1',
|
|
partnerName: '통신공사',
|
|
projectName: '강남역 통신시설 구축',
|
|
contractManagerId: 'hong',
|
|
contractManagerName: '홍길동',
|
|
constructionPMId: 'kim',
|
|
constructionPMName: '김PM',
|
|
totalLocations: 15,
|
|
contractAmount: 150000000,
|
|
contractStartDate: '2025-12-17',
|
|
contractEndDate: '2026-06-17',
|
|
status: 'pending',
|
|
stage: 'estimate_selected',
|
|
remarks: '',
|
|
createdAt: '2025-01-01',
|
|
updatedAt: '2025-01-01',
|
|
createdBy: 'system',
|
|
biddingId: '1',
|
|
biddingCode: 'BID-2025-001',
|
|
},
|
|
{
|
|
id: '2',
|
|
contractCode: 'CT-2025-002',
|
|
partnerId: '2',
|
|
partnerName: '야사건설',
|
|
projectName: '판교 IT단지 배선공사',
|
|
contractManagerId: 'hong',
|
|
contractManagerName: '홍길동',
|
|
constructionPMId: 'lee',
|
|
constructionPMName: '이PM',
|
|
totalLocations: 28,
|
|
contractAmount: 280000000,
|
|
contractStartDate: '2025-11-01',
|
|
contractEndDate: '2026-03-31',
|
|
status: 'pending',
|
|
stage: 'estimate_progress',
|
|
remarks: '',
|
|
createdAt: '2025-01-02',
|
|
updatedAt: '2025-01-02',
|
|
createdBy: 'system',
|
|
biddingId: '2',
|
|
biddingCode: 'BID-2025-002',
|
|
},
|
|
{
|
|
id: '3',
|
|
contractCode: 'CT-2025-003',
|
|
partnerId: '3',
|
|
partnerName: '여의건설',
|
|
projectName: '여의도 오피스빌딩 통신설비',
|
|
contractManagerId: 'kim',
|
|
contractManagerName: '김철수',
|
|
constructionPMId: 'park',
|
|
constructionPMName: '박PM',
|
|
totalLocations: 42,
|
|
contractAmount: 420000000,
|
|
contractStartDate: '2025-10-15',
|
|
contractEndDate: '2026-04-15',
|
|
status: 'pending',
|
|
stage: 'delivery',
|
|
remarks: '',
|
|
createdAt: '2025-01-03',
|
|
updatedAt: '2025-01-03',
|
|
createdBy: 'system',
|
|
biddingId: '3',
|
|
biddingCode: 'BID-2025-003',
|
|
},
|
|
{
|
|
id: '4',
|
|
contractCode: 'CT-2025-004',
|
|
partnerId: '1',
|
|
partnerName: '통신공사',
|
|
projectName: '송파 데이터센터 증설',
|
|
contractManagerId: 'hong',
|
|
contractManagerName: '홍길동',
|
|
constructionPMId: 'kim',
|
|
constructionPMName: '김PM',
|
|
totalLocations: 58,
|
|
contractAmount: 580000000,
|
|
contractStartDate: '2025-09-01',
|
|
contractEndDate: '2026-02-28',
|
|
status: 'completed',
|
|
stage: 'inspection',
|
|
remarks: '',
|
|
createdAt: '2025-01-04',
|
|
updatedAt: '2025-01-04',
|
|
createdBy: 'system',
|
|
biddingId: '4',
|
|
biddingCode: 'BID-2025-004',
|
|
},
|
|
{
|
|
id: '5',
|
|
contractCode: 'CT-2025-005',
|
|
partnerId: '2',
|
|
partnerName: '야사건설',
|
|
projectName: '분당 스마트빌딩 LAN공사',
|
|
contractManagerId: 'lee',
|
|
contractManagerName: '이영희',
|
|
constructionPMId: 'lee',
|
|
constructionPMName: '이PM',
|
|
totalLocations: 12,
|
|
contractAmount: 95000000,
|
|
contractStartDate: '2025-12-01',
|
|
contractEndDate: '2026-01-31',
|
|
status: 'pending',
|
|
stage: 'installation',
|
|
remarks: '',
|
|
createdAt: '2025-01-05',
|
|
updatedAt: '2025-01-05',
|
|
createdBy: 'system',
|
|
biddingId: '5',
|
|
biddingCode: 'BID-2025-005',
|
|
},
|
|
{
|
|
id: '6',
|
|
contractCode: 'CT-2025-006',
|
|
partnerId: '3',
|
|
partnerName: '여의건설',
|
|
projectName: '마포 복합시설 CCTV설치',
|
|
contractManagerId: 'hong',
|
|
contractManagerName: '홍길동',
|
|
constructionPMId: 'park',
|
|
constructionPMName: '박PM',
|
|
totalLocations: 8,
|
|
contractAmount: 75000000,
|
|
contractStartDate: '2025-08-01',
|
|
contractEndDate: '2025-10-31',
|
|
status: 'completed',
|
|
stage: 'estimate_selected',
|
|
remarks: '',
|
|
createdAt: '2025-01-06',
|
|
updatedAt: '2025-01-06',
|
|
createdBy: 'system',
|
|
biddingId: '6',
|
|
biddingCode: 'BID-2025-006',
|
|
},
|
|
{
|
|
id: '7',
|
|
contractCode: 'CT-2025-007',
|
|
partnerId: '1',
|
|
partnerName: '통신공사',
|
|
projectName: '용산 아파트 인터폰교체',
|
|
contractManagerId: 'kim',
|
|
contractManagerName: '김철수',
|
|
constructionPMId: 'kim',
|
|
constructionPMName: '김PM',
|
|
totalLocations: 120,
|
|
contractAmount: 45000000,
|
|
contractStartDate: '2025-07-15',
|
|
contractEndDate: '2025-09-15',
|
|
status: 'completed',
|
|
stage: 'estimate_progress',
|
|
remarks: '',
|
|
createdAt: '2025-01-07',
|
|
updatedAt: '2025-01-07',
|
|
createdBy: 'system',
|
|
biddingId: '7',
|
|
biddingCode: 'BID-2025-007',
|
|
},
|
|
{
|
|
id: '8',
|
|
contractCode: 'CT-2025-008',
|
|
partnerId: '2',
|
|
partnerName: '야사건설',
|
|
projectName: '성수동 공장 방범설비',
|
|
contractManagerId: 'lee',
|
|
contractManagerName: '이영희',
|
|
constructionPMId: 'lee',
|
|
constructionPMName: '이PM',
|
|
totalLocations: 24,
|
|
contractAmount: 120000000,
|
|
contractStartDate: '2025-11-15',
|
|
contractEndDate: '2026-02-15',
|
|
status: 'pending',
|
|
stage: 'other',
|
|
remarks: '',
|
|
createdAt: '2025-01-08',
|
|
updatedAt: '2025-01-08',
|
|
createdBy: 'system',
|
|
biddingId: '8',
|
|
biddingCode: 'BID-2025-008',
|
|
},
|
|
{
|
|
id: '9',
|
|
contractCode: 'CT-2025-009',
|
|
partnerId: '3',
|
|
partnerName: '여의건설',
|
|
projectName: '강서 물류센터 네트워크',
|
|
contractManagerId: 'hong',
|
|
contractManagerName: '홍길동',
|
|
constructionPMId: 'park',
|
|
constructionPMName: '박PM',
|
|
totalLocations: 35,
|
|
contractAmount: 320000000,
|
|
contractStartDate: '2025-06-01',
|
|
contractEndDate: '2025-11-30',
|
|
status: 'completed',
|
|
stage: 'inspection',
|
|
remarks: '',
|
|
createdAt: '2025-01-09',
|
|
updatedAt: '2025-01-09',
|
|
createdBy: 'system',
|
|
biddingId: '9',
|
|
biddingCode: 'BID-2025-009',
|
|
},
|
|
];
|
|
|
|
// 계약 목록 조회
|
|
export async function getContractList(filter?: ContractFilter): Promise<{
|
|
success: boolean;
|
|
data?: ContractListResponse;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
|
|
let filteredData = [...MOCK_CONTRACTS];
|
|
|
|
// 검색 필터
|
|
if (filter?.search) {
|
|
const search = filter.search.toLowerCase();
|
|
filteredData = filteredData.filter(
|
|
(item) =>
|
|
item.contractCode.toLowerCase().includes(search) ||
|
|
item.partnerName.toLowerCase().includes(search) ||
|
|
item.projectName.toLowerCase().includes(search)
|
|
);
|
|
}
|
|
|
|
// 상태 필터
|
|
if (filter?.status && filter.status !== 'all') {
|
|
filteredData = filteredData.filter((item) => item.status === filter.status);
|
|
}
|
|
|
|
// 단계 필터
|
|
if (filter?.stage && filter.stage !== 'all') {
|
|
filteredData = filteredData.filter((item) => item.stage === filter.stage);
|
|
}
|
|
|
|
// 거래처 필터
|
|
if (filter?.partnerId && filter.partnerId !== 'all') {
|
|
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
|
|
}
|
|
|
|
// 계약담당자 필터
|
|
if (filter?.contractManagerId && filter.contractManagerId !== 'all') {
|
|
filteredData = filteredData.filter((item) => item.contractManagerId === filter.contractManagerId);
|
|
}
|
|
|
|
// 공사PM 필터
|
|
if (filter?.constructionPMId && filter.constructionPMId !== 'all') {
|
|
filteredData = filteredData.filter((item) => item.constructionPMId === filter.constructionPMId);
|
|
}
|
|
|
|
// 날짜 필터
|
|
if (filter?.startDate) {
|
|
filteredData = filteredData.filter(
|
|
(item) => item.contractStartDate && item.contractStartDate >= filter.startDate!
|
|
);
|
|
}
|
|
if (filter?.endDate) {
|
|
filteredData = filteredData.filter(
|
|
(item) => item.contractEndDate && item.contractEndDate <= filter.endDate!
|
|
);
|
|
}
|
|
|
|
// 정렬
|
|
const sortBy = filter?.sortBy || 'contractDateDesc';
|
|
switch (sortBy) {
|
|
case 'contractDateDesc':
|
|
filteredData.sort((a, b) => {
|
|
if (!a.contractStartDate) return 1;
|
|
if (!b.contractStartDate) return -1;
|
|
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
|
});
|
|
break;
|
|
case 'contractDateAsc':
|
|
filteredData.sort((a, b) => {
|
|
if (!a.contractStartDate) return 1;
|
|
if (!b.contractStartDate) return -1;
|
|
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
|
});
|
|
break;
|
|
case 'partnerNameAsc':
|
|
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
|
break;
|
|
case 'partnerNameDesc':
|
|
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
|
break;
|
|
case 'projectNameAsc':
|
|
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
|
break;
|
|
case 'projectNameDesc':
|
|
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
|
break;
|
|
case 'amountDesc':
|
|
filteredData.sort((a, b) => b.contractAmount - a.contractAmount);
|
|
break;
|
|
case 'amountAsc':
|
|
filteredData.sort((a, b) => a.contractAmount - b.contractAmount);
|
|
break;
|
|
}
|
|
|
|
// 페이지네이션
|
|
const page = filter?.page || 1;
|
|
const size = filter?.size || 20;
|
|
const startIndex = (page - 1) * size;
|
|
const paginatedData = filteredData.slice(startIndex, startIndex + size);
|
|
|
|
return {
|
|
success: true,
|
|
data: {
|
|
items: paginatedData,
|
|
total: filteredData.length,
|
|
page,
|
|
size,
|
|
totalPages: Math.ceil(filteredData.length / size),
|
|
},
|
|
};
|
|
} catch (error) {
|
|
console.error('getContractList error:', error);
|
|
return { success: false, error: '계약 목록을 불러오는데 실패했습니다.' };
|
|
}
|
|
}
|
|
|
|
// 계약 통계 조회
|
|
export async function getContractStats(): Promise<{
|
|
success: boolean;
|
|
data?: ContractStats;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
const stats: ContractStats = {
|
|
total: MOCK_CONTRACTS.length,
|
|
pending: MOCK_CONTRACTS.filter((c) => c.status === 'pending').length,
|
|
completed: MOCK_CONTRACTS.filter((c) => c.status === 'completed').length,
|
|
};
|
|
|
|
return { success: true, data: stats };
|
|
} catch (error) {
|
|
console.error('getContractStats error:', error);
|
|
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
|
}
|
|
}
|
|
|
|
// 단계별 건수 조회
|
|
export async function getContractStageCounts(): Promise<{
|
|
success: boolean;
|
|
data?: ContractStageCount;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
|
|
const counts: ContractStageCount = {
|
|
estimateSelected: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_selected').length,
|
|
estimateProgress: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_progress').length,
|
|
delivery: MOCK_CONTRACTS.filter((c) => c.stage === 'delivery').length,
|
|
installation: MOCK_CONTRACTS.filter((c) => c.stage === 'installation').length,
|
|
inspection: MOCK_CONTRACTS.filter((c) => c.stage === 'inspection').length,
|
|
other: MOCK_CONTRACTS.filter((c) => c.stage === 'other').length,
|
|
};
|
|
|
|
return { success: true, data: counts };
|
|
} catch (error) {
|
|
console.error('getContractStageCounts error:', error);
|
|
return { success: false, error: '단계별 건수를 불러오는데 실패했습니다.' };
|
|
}
|
|
}
|
|
|
|
// 계약 단건 조회
|
|
export async function getContract(id: string): Promise<{
|
|
success: boolean;
|
|
data?: Contract;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
|
|
if (!contract) {
|
|
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
|
}
|
|
|
|
return { success: true, data: contract };
|
|
} catch (error) {
|
|
console.error('getContract error:', error);
|
|
return { success: false, error: '계약 정보를 불러오는데 실패했습니다.' };
|
|
}
|
|
}
|
|
|
|
// 계약 삭제
|
|
export async function deleteContract(id: string): Promise<{
|
|
success: boolean;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
|
|
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
|
|
if (index === -1) {
|
|
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
|
}
|
|
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('deleteContract error:', error);
|
|
return { success: false, error: '계약 삭제에 실패했습니다.' };
|
|
}
|
|
}
|
|
|
|
// 계약 일괄 삭제
|
|
export async function deleteContracts(ids: string[]): Promise<{
|
|
success: boolean;
|
|
deletedCount?: number;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
return { success: true, deletedCount: ids.length };
|
|
} catch (error) {
|
|
console.error('deleteContracts error:', error);
|
|
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
|
}
|
|
}
|
|
|
|
// 계약 상세 조회 (첨부파일 포함)
|
|
export async function getContractDetail(id: string): Promise<{
|
|
success: boolean;
|
|
data?: ContractDetail;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
|
|
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
|
|
if (!contract) {
|
|
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
|
}
|
|
|
|
// ContractDetail로 변환 (첨부파일 목데이터 포함)
|
|
const contractDetail: ContractDetail = {
|
|
...contract,
|
|
// 계약서 파일 목업 데이터
|
|
contractFile: {
|
|
id: '100',
|
|
fileName: '계약서_CT-2025-001.pdf',
|
|
fileUrl: '/files/contract.pdf',
|
|
uploadedAt: contract.createdAt,
|
|
},
|
|
attachments: [
|
|
{
|
|
id: 'att-1',
|
|
fileName: '견적서.pdf',
|
|
fileSize: 1024000,
|
|
fileUrl: '/files/estimate.pdf',
|
|
uploadedAt: contract.createdAt,
|
|
},
|
|
{
|
|
id: 'att-2',
|
|
fileName: '시방서.pdf',
|
|
fileSize: 2048000,
|
|
fileUrl: '/files/spec.pdf',
|
|
uploadedAt: contract.createdAt,
|
|
},
|
|
],
|
|
};
|
|
|
|
return { success: true, data: contractDetail };
|
|
} catch (error) {
|
|
console.error('getContractDetail error:', error);
|
|
return { success: false, error: '계약 상세 정보를 불러오는데 실패했습니다.' };
|
|
}
|
|
}
|
|
|
|
// 계약 수정
|
|
export async function updateContract(
|
|
id: string,
|
|
_data: Partial<ContractFormData>
|
|
): Promise<{
|
|
success: boolean;
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
|
|
if (index === -1) {
|
|
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
|
}
|
|
|
|
// TODO: 실제 API 연동 시 데이터 업데이트 로직
|
|
return { success: true };
|
|
} catch (error) {
|
|
console.error('updateContract error:', error);
|
|
return { success: false, error: '계약 수정에 실패했습니다.' };
|
|
}
|
|
}
|
|
|
|
// 계약 생성 (변경 계약서 생성 포함)
|
|
export async function createContract(
|
|
_data: ContractFormData
|
|
): Promise<{
|
|
success: boolean;
|
|
data?: { id: string };
|
|
error?: string;
|
|
}> {
|
|
try {
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
|
|
// TODO: 실제 API 연동 시 데이터 생성 로직
|
|
// 새 계약 ID 생성 (목업)
|
|
const newId = String(MOCK_CONTRACTS.length + 1);
|
|
|
|
return { success: true, data: { id: newId } };
|
|
} catch (error) {
|
|
console.error('createContract error:', error);
|
|
return { success: false, error: '계약 생성에 실패했습니다.' };
|
|
}
|
|
} |