Merge branch 'master' into feature/universal-list-component
This commit is contained in:
@@ -75,11 +75,13 @@ export function ApprovalLineSection({ data, onChange }: ApprovalLineSectionProps
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼">
|
||||
{person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: null}
|
||||
</SelectValue>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: "부서명 / 직책명 / 이름 ▼"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
|
||||
@@ -75,11 +75,13 @@ export function ReferenceSection({ data, onChange }: ReferenceSectionProps) {
|
||||
onValueChange={(value) => handleChange(index, value)}
|
||||
>
|
||||
<SelectTrigger className="flex-1">
|
||||
<SelectValue placeholder="부서명 / 직책명 / 이름 ▼">
|
||||
{person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: null}
|
||||
</SelectValue>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
person.name && !person.id.startsWith('temp-')
|
||||
? `${person.department || ''} / ${person.position || ''} / ${person.name}`
|
||||
: "부서명 / 직책명 / 이름 ▼"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{employees.map((employee) => (
|
||||
|
||||
@@ -457,4 +457,4 @@ export async function cancelDraft(id: string): Promise<{ success: boolean; error
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Trash2,
|
||||
Plus,
|
||||
Pencil,
|
||||
Bell,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
submitDraft,
|
||||
submitDrafts,
|
||||
} from './actions';
|
||||
import { sendApprovalNotification } from '@/lib/actions/fcm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -243,6 +245,24 @@ export function DraftBox() {
|
||||
router.push('/ko/approval/draft/new');
|
||||
}, [router]);
|
||||
|
||||
// ===== FCM 알림 발송 핸들러 =====
|
||||
const handleSendNotification = useCallback(async () => {
|
||||
startTransition(async () => {
|
||||
try {
|
||||
const result = await sendApprovalNotification();
|
||||
if (result.success) {
|
||||
toast.success(`결재 알림을 발송했습니다. (${result.sentCount || 0}건)`);
|
||||
} else {
|
||||
toast.error(result.error || '알림 발송에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Notification error:', error);
|
||||
toast.error('알림 발송 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 문서 클릭/수정 핸들러 (조건부 로직) =====
|
||||
// 임시저장 → 문서 작성 페이지 (수정 모드)
|
||||
// 그 외 → 문서 상세 모달 (상세 API 호출하여 content 포함된 데이터 가져옴)
|
||||
@@ -597,6 +617,10 @@ export function DraftBox() {
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button variant="outline" onClick={handleSendNotification}>
|
||||
<Bell className="h-4 w-4 mr-2" />
|
||||
문서완료
|
||||
</Button>
|
||||
<Button onClick={handleNewDocument}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
문서 작성
|
||||
|
||||
@@ -1,35 +1,79 @@
|
||||
'use server';
|
||||
|
||||
import type { Category } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// ===== 목데이터 (추후 API 연동 시 교체) =====
|
||||
let mockCategories: Category[] = [
|
||||
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
|
||||
{ id: '2', name: '모터', order: 2, isDefault: true },
|
||||
{ id: '3', name: '공정자재', order: 3, isDefault: true },
|
||||
{ id: '4', name: '철물', order: 4, isDefault: true },
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 카테고리 관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 다음 ID 생성
|
||||
let nextId = 5;
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
// ===== 카테고리 목록 조회 =====
|
||||
interface ApiCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
sort_order: number;
|
||||
is_default: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Category 타입 변환
|
||||
*/
|
||||
function transformCategory(apiData: ApiCategory): Category {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
name: apiData.name || '',
|
||||
order: apiData.sort_order || 0,
|
||||
isDefault: apiData.is_default || false,
|
||||
isActive: apiData.is_active !== false,
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 카테고리 목록 조회
|
||||
* GET /api/v1/categories
|
||||
*/
|
||||
export async function getCategories(): Promise<{
|
||||
success: boolean;
|
||||
data?: Category[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목데이터 반환 (순서대로 정렬)
|
||||
const sortedCategories = [...mockCategories].sort((a, b) => a.order - b.order);
|
||||
return { success: true, data: sortedCategories };
|
||||
const response = await apiClient.get<{
|
||||
data: ApiCategory[];
|
||||
}>('/categories', { params: { per_page: '100' } });
|
||||
|
||||
const categories = (response.data || [])
|
||||
.map(transformCategory)
|
||||
.sort((a, b) => a.order - b.order);
|
||||
|
||||
return { success: true, data: categories };
|
||||
} catch (error) {
|
||||
console.error('[getCategories] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('카테고리 목록 조회 오류:', error);
|
||||
return { success: false, error: '카테고리 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 생성 =====
|
||||
/**
|
||||
* 카테고리 생성
|
||||
* POST /api/v1/categories
|
||||
*/
|
||||
export async function createCategory(data: {
|
||||
name: string;
|
||||
}): Promise<{
|
||||
@@ -38,22 +82,20 @@ export async function createCategory(data: {
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const newCategory: Category = {
|
||||
id: String(nextId++),
|
||||
const response = await apiClient.post<ApiCategory>('/categories', {
|
||||
name: data.name,
|
||||
order: mockCategories.length + 1,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
mockCategories.push(newCategory);
|
||||
return { success: true, data: newCategory };
|
||||
});
|
||||
return { success: true, data: transformCategory(response) };
|
||||
} catch (error) {
|
||||
console.error('[createCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('카테고리 생성 오류:', error);
|
||||
return { success: false, error: '카테고리 생성에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 수정 =====
|
||||
/**
|
||||
* 카테고리 수정
|
||||
* PATCH /api/v1/categories/{id}
|
||||
*/
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
data: { name?: string }
|
||||
@@ -63,65 +105,64 @@ export async function updateCategory(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockCategories.findIndex(c => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '카테고리를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
mockCategories[index] = {
|
||||
...mockCategories[index],
|
||||
...data,
|
||||
};
|
||||
|
||||
return { success: true, data: mockCategories[index] };
|
||||
const response = await apiClient.patch<ApiCategory>(`/categories/${id}`, {
|
||||
name: data.name,
|
||||
});
|
||||
return { success: true, data: transformCategory(response) };
|
||||
} catch (error) {
|
||||
console.error('[updateCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('카테고리 수정 오류:', error);
|
||||
return { success: false, error: '카테고리 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 삭제 =====
|
||||
/**
|
||||
* 카테고리 삭제
|
||||
* DELETE /api/v1/categories/{id}
|
||||
*/
|
||||
export async function deleteCategory(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
errorType?: 'IN_USE' | 'DEFAULT' | 'GENERAL';
|
||||
}> {
|
||||
try {
|
||||
const category = mockCategories.find(c => c.id === id);
|
||||
await apiClient.delete(`/categories/${id}`);
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
console.error('카테고리 삭제 오류:', error);
|
||||
|
||||
if (!category) {
|
||||
return { success: false, error: '카테고리를 찾을 수 없습니다.', errorType: 'GENERAL' };
|
||||
}
|
||||
// API 에러 응답에서 errorType 추출
|
||||
const apiError = error as { response?: { data?: { error_type?: string; message?: string } } };
|
||||
const errorType = apiError?.response?.data?.error_type;
|
||||
const errorMessage = apiError?.response?.data?.message;
|
||||
|
||||
// 기본 카테고리는 삭제 불가
|
||||
if (category.isDefault) {
|
||||
if (errorType === 'IN_USE') {
|
||||
return {
|
||||
success: false,
|
||||
error: '기본 카테고리는 삭제가 불가합니다.',
|
||||
errorType: 'DEFAULT'
|
||||
error: errorMessage || '해당 카테고리를 사용하고 있는 품목이 있습니다.',
|
||||
errorType: 'IN_USE',
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: 품목 사용 여부 체크 로직 (추후 API 연동 시)
|
||||
// 현재는 목데이터이므로 사용 중인 품목이 없다고 가정
|
||||
// const itemsUsingCategory = await checkItemsUsingCategory(id);
|
||||
// if (itemsUsingCategory.length > 0) {
|
||||
// return {
|
||||
// success: false,
|
||||
// error: `"${category.name}"을(를) 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다.`,
|
||||
// errorType: 'IN_USE'
|
||||
// };
|
||||
// }
|
||||
if (errorType === 'DEFAULT') {
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage || '기본 카테고리는 삭제가 불가합니다.',
|
||||
errorType: 'DEFAULT',
|
||||
};
|
||||
}
|
||||
|
||||
mockCategories = mockCategories.filter(c => c.id !== id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[deleteCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.', errorType: 'GENERAL' };
|
||||
return {
|
||||
success: false,
|
||||
error: '카테고리 삭제에 실패했습니다.',
|
||||
errorType: 'GENERAL',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 순서 변경 =====
|
||||
/**
|
||||
* 카테고리 순서 변경
|
||||
* POST /api/v1/categories/reorder
|
||||
*/
|
||||
export async function reorderCategories(
|
||||
items: { id: string; sort_order: number }[]
|
||||
): Promise<{
|
||||
@@ -129,17 +170,15 @@ export async function reorderCategories(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 순서 업데이트
|
||||
items.forEach(item => {
|
||||
const category = mockCategories.find(c => c.id === item.id);
|
||||
if (category) {
|
||||
category.order = item.sort_order;
|
||||
}
|
||||
await apiClient.post('/categories/reorder', {
|
||||
items: items.map((item) => ({
|
||||
id: Number(item.id),
|
||||
sort_order: item.sort_order,
|
||||
})),
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[reorderCategories] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('카테고리 순서 변경 오류:', error);
|
||||
return { success: false, error: '순서 변경에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -9,531 +9,402 @@ import type {
|
||||
ContractFilter,
|
||||
ContractFormData,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목업 데이터
|
||||
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',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 계약관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 계약 목록 조회
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiContract {
|
||||
id: number;
|
||||
contract_code: string;
|
||||
partner_id: number | null;
|
||||
partner_name: string | null;
|
||||
project_name: string;
|
||||
contract_manager_id: number | null;
|
||||
contract_manager_name: string | null;
|
||||
construction_pm_id: number | null;
|
||||
construction_pm_name: string | null;
|
||||
total_locations: number;
|
||||
contract_amount: number;
|
||||
contract_start_date: string | null;
|
||||
contract_end_date: string | null;
|
||||
status: 'pending' | 'completed';
|
||||
stage: string;
|
||||
remarks: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
bidding_id: number | null;
|
||||
bidding_code: string | null;
|
||||
contract_file?: ApiContractFile | null;
|
||||
attachments?: ApiAttachment[];
|
||||
}
|
||||
|
||||
interface ApiContractFile {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_url: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
interface ApiAttachment {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_size: number;
|
||||
file_url: string;
|
||||
uploaded_at: string;
|
||||
}
|
||||
|
||||
interface ApiContractStats {
|
||||
total_count: number;
|
||||
pending_count: number;
|
||||
completed_count: number;
|
||||
}
|
||||
|
||||
interface ApiContractStageCount {
|
||||
estimate_selected: number;
|
||||
estimate_progress: number;
|
||||
delivery: number;
|
||||
installation: number;
|
||||
inspection: number;
|
||||
other: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Contract 타입 변환
|
||||
*/
|
||||
function transformContract(apiData: ApiContract): Contract {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
contractCode: apiData.contract_code || '',
|
||||
partnerId: apiData.partner_id ? String(apiData.partner_id) : '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
projectName: apiData.project_name || '',
|
||||
contractManagerId: apiData.contract_manager_id ? String(apiData.contract_manager_id) : '',
|
||||
contractManagerName: apiData.contract_manager_name || '',
|
||||
constructionPMId: apiData.construction_pm_id ? String(apiData.construction_pm_id) : '',
|
||||
constructionPMName: apiData.construction_pm_name || '',
|
||||
totalLocations: apiData.total_locations || 0,
|
||||
contractAmount: apiData.contract_amount || 0,
|
||||
contractStartDate: apiData.contract_start_date || null,
|
||||
contractEndDate: apiData.contract_end_date || null,
|
||||
status: apiData.status || 'pending',
|
||||
stage: (apiData.stage as Contract['stage']) || 'estimate_selected',
|
||||
remarks: apiData.remarks || '',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
createdBy: apiData.created_by || '',
|
||||
biddingId: apiData.bidding_id ? String(apiData.bidding_id) : '',
|
||||
biddingCode: apiData.bidding_code || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → ContractDetail 타입 변환
|
||||
*/
|
||||
function transformContractDetail(apiData: ApiContract): ContractDetail {
|
||||
const contract = transformContract(apiData);
|
||||
|
||||
return {
|
||||
...contract,
|
||||
contractFile: apiData.contract_file
|
||||
? {
|
||||
id: String(apiData.contract_file.id),
|
||||
fileName: apiData.contract_file.file_name || '',
|
||||
fileUrl: apiData.contract_file.file_url || '',
|
||||
uploadedAt: apiData.contract_file.uploaded_at || '',
|
||||
}
|
||||
: null,
|
||||
attachments: (apiData.attachments || []).map((att) => ({
|
||||
id: String(att.id),
|
||||
fileName: att.file_name || '',
|
||||
fileSize: att.file_size || 0,
|
||||
fileUrl: att.file_url || '',
|
||||
uploadedAt: att.uploaded_at || '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ContractFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<ContractFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.contractCode !== undefined) apiData.contract_code = data.contractCode;
|
||||
if (data.projectName !== undefined) apiData.project_name = data.projectName;
|
||||
if (data.partnerId !== undefined) apiData.partner_id = data.partnerId || null;
|
||||
if (data.partnerName !== undefined) apiData.partner_name = data.partnerName || null;
|
||||
if (data.contractManagerId !== undefined) apiData.contract_manager_id = data.contractManagerId || null;
|
||||
if (data.contractManagerName !== undefined) apiData.contract_manager_name = data.contractManagerName || null;
|
||||
if (data.totalLocations !== undefined) apiData.total_locations = data.totalLocations;
|
||||
if (data.contractAmount !== undefined) apiData.contract_amount = data.contractAmount;
|
||||
if (data.contractStartDate !== undefined) apiData.contract_start_date = data.contractStartDate || null;
|
||||
if (data.contractEndDate !== undefined) apiData.contract_end_date = data.contractEndDate || null;
|
||||
if (data.status !== undefined) apiData.status = data.status;
|
||||
if (data.remarks !== undefined) apiData.remarks = data.remarks || null;
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 계약 목록 조회
|
||||
* GET /api/v1/construction/contracts
|
||||
*/
|
||||
export async function getContractList(filter?: ContractFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
let filteredData = [...MOCK_CONTRACTS];
|
||||
// 검색
|
||||
if (filter?.search) queryParams.search = filter.search;
|
||||
|
||||
// 검색 필터
|
||||
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?.status && filter.status !== 'all') queryParams.status = filter.status;
|
||||
if (filter?.stage && filter.stage !== 'all') queryParams.stage = filter.stage;
|
||||
if (filter?.partnerId && filter.partnerId !== 'all') queryParams.partner_id = filter.partnerId;
|
||||
if (filter?.contractManagerId && filter.contractManagerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.contractManagerId === filter.contractManagerId);
|
||||
queryParams.contract_manager_id = filter.contractManagerId;
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
if (filter?.constructionPMId && filter.constructionPMId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.constructionPMId === filter.constructionPMId);
|
||||
queryParams.construction_pm_id = 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;
|
||||
}
|
||||
// 날짜 범위
|
||||
if (filter?.startDate) queryParams.start_date = filter.startDate;
|
||||
if (filter?.endDate) queryParams.end_date = filter.endDate;
|
||||
|
||||
// 페이지네이션
|
||||
const page = filter?.page || 1;
|
||||
const size = filter?.size || 20;
|
||||
const startIndex = (page - 1) * size;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + size);
|
||||
if (filter?.page) queryParams.page = String(filter.page);
|
||||
if (filter?.size) queryParams.per_page = String(filter.size);
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
contractDateDesc: { field: 'created_at', dir: 'desc' },
|
||||
contractDateAsc: { field: 'created_at', dir: 'asc' },
|
||||
partnerNameAsc: { field: 'partner_name', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'partner_name', dir: 'desc' },
|
||||
projectNameAsc: { field: 'project_name', dir: 'asc' },
|
||||
projectNameDesc: { field: 'project_name', dir: 'desc' },
|
||||
amountDesc: { field: 'contract_amount', dir: 'desc' },
|
||||
amountAsc: { field: 'contract_amount', dir: 'asc' },
|
||||
};
|
||||
const sort = sortMap[filter.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiContract[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/construction/contracts', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformContract);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedData,
|
||||
total: filteredData.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filteredData.length / size),
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getContractList error:', error);
|
||||
console.error('계약 목록 조회 오류:', error);
|
||||
return { success: false, error: '계약 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 통계 조회
|
||||
/**
|
||||
* 계약 통계 조회
|
||||
* GET /api/v1/construction/contracts/stats
|
||||
*/
|
||||
export async function getContractStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const response = await apiClient.get<ApiContractStats>('/construction/contracts/stats');
|
||||
|
||||
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: {
|
||||
total: response.total_count || 0,
|
||||
pending: response.pending_count || 0,
|
||||
completed: response.completed_count || 0,
|
||||
},
|
||||
};
|
||||
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
console.error('getContractStats error:', error);
|
||||
console.error('계약 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 단계별 건수 조회
|
||||
/**
|
||||
* 단계별 건수 조회
|
||||
* GET /api/v1/construction/contracts/stage-counts
|
||||
*/
|
||||
export async function getContractStageCounts(): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractStageCount;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const response = await apiClient.get<ApiContractStageCount>('/construction/contracts/stage-counts');
|
||||
|
||||
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: {
|
||||
estimateSelected: response.estimate_selected || 0,
|
||||
estimateProgress: response.estimate_progress || 0,
|
||||
delivery: response.delivery || 0,
|
||||
installation: response.installation || 0,
|
||||
inspection: response.inspection || 0,
|
||||
other: response.other || 0,
|
||||
},
|
||||
};
|
||||
|
||||
return { success: true, data: counts };
|
||||
} catch (error) {
|
||||
console.error('getContractStageCounts error:', error);
|
||||
console.error('단계별 건수 조회 오류:', error);
|
||||
return { success: false, error: '단계별 건수를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 단건 조회
|
||||
/**
|
||||
* 계약 단건 조회
|
||||
* GET /api/v1/construction/contracts/{id}
|
||||
*/
|
||||
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 };
|
||||
const response = await apiClient.get<ApiContract>(`/construction/contracts/${id}`);
|
||||
return { success: true, data: transformContract(response) };
|
||||
} catch (error) {
|
||||
console.error('getContract error:', error);
|
||||
return { success: false, error: '계약 정보를 불러오는데 실패했습니다.' };
|
||||
console.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: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 상세 조회 (첨부파일 포함)
|
||||
/**
|
||||
* 계약 상세 조회 (첨부파일 포함)
|
||||
* GET /api/v1/construction/contracts/{id}
|
||||
*/
|
||||
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 };
|
||||
const response = await apiClient.get<ApiContract>(`/construction/contracts/${id}`);
|
||||
return { success: true, data: transformContractDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('getContractDetail error:', error);
|
||||
console.error('계약 상세 조회 오류:', error);
|
||||
return { success: false, error: '계약 상세 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 수정
|
||||
export async function updateContract(
|
||||
id: string,
|
||||
_data: Partial<ContractFormData>
|
||||
): Promise<{
|
||||
/**
|
||||
* 계약 등록
|
||||
* POST /api/v1/construction/contracts
|
||||
*/
|
||||
export async function createContract(data: ContractFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Contract;
|
||||
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 };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.post<ApiContract>('/construction/contracts', apiData);
|
||||
return { success: true, data: transformContract(response) };
|
||||
} catch (error) {
|
||||
console.error('updateContract error:', error);
|
||||
console.error('계약 등록 오류:', error);
|
||||
return { success: false, error: '계약 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 수정
|
||||
* PUT /api/v1/construction/contracts/{id}
|
||||
*/
|
||||
export async function updateContract(
|
||||
id: string,
|
||||
data: Partial<ContractFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Contract;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiContract>(`/construction/contracts/${id}`, apiData);
|
||||
return { success: true, data: transformContract(response) };
|
||||
} catch (error) {
|
||||
console.error('계약 수정 오류:', error);
|
||||
return { success: false, error: '계약 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 생성 (변경 계약서 생성 포함)
|
||||
export async function createContract(
|
||||
_data: ContractFormData
|
||||
): Promise<{
|
||||
/**
|
||||
* 계약 삭제
|
||||
* DELETE /api/v1/construction/contracts/{id}
|
||||
*/
|
||||
export async function deleteContract(id: string): 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 } };
|
||||
await apiClient.delete(`/construction/contracts/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('createContract error:', error);
|
||||
return { success: false, error: '계약 생성에 실패했습니다.' };
|
||||
console.error('계약 삭제 오류:', error);
|
||||
return { success: false, error: '계약 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계약 일괄 삭제
|
||||
* DELETE /api/v1/construction/contracts/bulk
|
||||
*/
|
||||
export async function deleteContracts(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.delete('/construction/contracts/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('계약 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getEstimateList({
|
||||
size: 1000,
|
||||
size: 100, // API 최대값 100
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
|
||||
@@ -1,279 +1,642 @@
|
||||
'use server';
|
||||
|
||||
import type { Estimate, EstimateStats, EstimateFilter, EstimateListResponse } from './types';
|
||||
import type {
|
||||
Estimate,
|
||||
EstimateDetail,
|
||||
EstimateStats,
|
||||
EstimateFilter,
|
||||
EstimateListResponse,
|
||||
EstimateDetailFormData,
|
||||
EstimateSummaryItem,
|
||||
ExpenseItem,
|
||||
PriceAdjustmentItem,
|
||||
EstimateDetailItem,
|
||||
SiteBriefingInfo,
|
||||
BidInfo,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* 주일 기업 - 견적관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
* 건설 프로젝트 - 견적관리 Server Actions
|
||||
* quotes API 사용 (quote_type=construction 필터)
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
const mockEstimates: Estimate[] = [
|
||||
{
|
||||
id: '1',
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '삼성 엘에이 사옥',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
estimateCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '야사 대림아파트',
|
||||
projectName: '마포 물류센터 증축',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
estimateCode: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '여의 현장아파트',
|
||||
projectName: '여의도 상업시설 신축',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 21,
|
||||
estimateAmount: 50000000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '강남 오피스텔 신축',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
completedDate: '2025-12-10',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
estimateCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '야사 대림아파트',
|
||||
projectName: '서초 아파트 리모델링',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
completedDate: '2025-12-11',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
estimateCode: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '회사명',
|
||||
projectName: '송파 주상복합 공사',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
completedDate: '2025-12-12',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
estimateCode: '123125',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '판교 테크노밸리 빌딩',
|
||||
estimatorId: 'kim',
|
||||
estimatorName: '김철수',
|
||||
itemCount: 15,
|
||||
estimateAmount: 200000000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-20',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: '김철수',
|
||||
},
|
||||
];
|
||||
// ========================================
|
||||
// API 응답 타입 (Quotes API)
|
||||
// ========================================
|
||||
|
||||
// 견적 목록 조회
|
||||
export async function getEstimateList(
|
||||
filter?: EstimateFilter
|
||||
): Promise<{ success: boolean; data?: EstimateListResponse; error?: string }> {
|
||||
interface ApiQuote {
|
||||
id: number;
|
||||
quote_type: string;
|
||||
quote_number: string;
|
||||
registration_date: string;
|
||||
client_id: number | null;
|
||||
client_name: string | null;
|
||||
site_id: number | null;
|
||||
site_name: string | null;
|
||||
site_briefing_id: number | null;
|
||||
product_category: string | null;
|
||||
product_name: string | null;
|
||||
total_amount: number | string;
|
||||
status: string;
|
||||
author: string | null;
|
||||
manager: string | null;
|
||||
remarks: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: number | null;
|
||||
// 연관 데이터
|
||||
items?: ApiQuoteItem[];
|
||||
site_briefing?: ApiSiteBriefing;
|
||||
// 옵션 데이터 (JSON)
|
||||
options?: ApiQuoteOptions;
|
||||
}
|
||||
|
||||
interface ApiQuoteOptions {
|
||||
summary_items?: ApiSummaryItem[];
|
||||
expense_items?: ApiExpenseItem[];
|
||||
price_adjustments?: ApiPriceAdjustment[];
|
||||
}
|
||||
|
||||
interface ApiSummaryItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
material_cost: number;
|
||||
labor_cost: number;
|
||||
total_cost: number;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
interface ApiExpenseItem {
|
||||
id: string;
|
||||
name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface ApiPriceAdjustment {
|
||||
id: string;
|
||||
category: string;
|
||||
unit_price: number;
|
||||
coating: number;
|
||||
batting: number;
|
||||
box_reinforce: number;
|
||||
painting: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ApiQuoteItem {
|
||||
id: number;
|
||||
item_code: string;
|
||||
item_name: string;
|
||||
specification: string | null;
|
||||
unit: string;
|
||||
base_quantity: number;
|
||||
calculated_quantity: number;
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
formula: string | null;
|
||||
note: string | null;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface ApiSiteBriefing {
|
||||
id: number;
|
||||
briefing_code: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
briefing_date: string;
|
||||
briefing_time: string | null;
|
||||
location: string | null;
|
||||
address: string | null;
|
||||
status: string;
|
||||
bid_status: string;
|
||||
bid_date: string | null;
|
||||
attendance_status: string;
|
||||
attendees: Array<{ name: string; department?: string }> | null;
|
||||
attendee_count: number;
|
||||
site_count: number;
|
||||
construction_start_date: string | null;
|
||||
construction_end_date: string | null;
|
||||
vat_type: string;
|
||||
partner?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiQuoteStats {
|
||||
total_count: number;
|
||||
pending_count: number;
|
||||
completed_count: number;
|
||||
}
|
||||
|
||||
// Legacy API types (for backward compatibility)
|
||||
interface ApiSiteBriefingInfo {
|
||||
briefing_code: string;
|
||||
partner_name: string;
|
||||
company_name: string;
|
||||
briefing_date: string;
|
||||
attendee: string;
|
||||
}
|
||||
|
||||
interface ApiBidInfo {
|
||||
project_name: string;
|
||||
bid_date: string;
|
||||
site_count: number;
|
||||
construction_period: string;
|
||||
construction_start_date: string;
|
||||
construction_end_date: string;
|
||||
vat_type: string;
|
||||
work_report: string;
|
||||
documents: ApiBidDocument[];
|
||||
}
|
||||
|
||||
interface ApiBidDocument {
|
||||
id: number;
|
||||
file_name: string;
|
||||
file_url: string;
|
||||
file_size: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 (Quote) → Estimate 타입 변환
|
||||
* 기존 프론트엔드 타입과 호환성 유지
|
||||
*/
|
||||
function transformQuoteToEstimate(apiData: ApiQuote): Estimate {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
estimateCode: apiData.quote_number || '',
|
||||
partnerId: apiData.client_id ? String(apiData.client_id) : '',
|
||||
partnerName: apiData.client_name || '',
|
||||
projectName: apiData.site_name || '',
|
||||
estimatorId: apiData.created_by ? String(apiData.created_by) : '',
|
||||
estimatorName: apiData.author || '',
|
||||
itemCount: apiData.items?.length || 0,
|
||||
estimateAmount: Number(apiData.total_amount) || 0,
|
||||
completedDate: null,
|
||||
bidDate: apiData.registration_date || null,
|
||||
status: mapQuoteStatusToEstimateStatus(apiData.status),
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
createdBy: apiData.created_by ? String(apiData.created_by) : '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote 상태 → Estimate 상태 매핑
|
||||
*/
|
||||
function mapQuoteStatusToEstimateStatus(
|
||||
quoteStatus: string
|
||||
): 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold' {
|
||||
const statusMap: Record<string, 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold'> = {
|
||||
pending: 'pending',
|
||||
draft: 'pending',
|
||||
sent: 'approval_waiting',
|
||||
approved: 'completed',
|
||||
rejected: 'rejected',
|
||||
finalized: 'completed',
|
||||
converted: 'completed',
|
||||
};
|
||||
return statusMap[quoteStatus] || 'pending';
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → EstimateDetail 타입 변환
|
||||
*/
|
||||
function transformQuoteToEstimateDetail(apiData: ApiQuote): EstimateDetail {
|
||||
const base = transformQuoteToEstimate(apiData);
|
||||
const sb = apiData.site_briefing;
|
||||
|
||||
// 참석자 정보 변환
|
||||
const attendeeNames = sb?.attendees
|
||||
? sb.attendees.map((a) => a.name).join(', ')
|
||||
: '';
|
||||
|
||||
// 공사기간 문자열 생성
|
||||
const constructionPeriod =
|
||||
sb?.construction_start_date && sb?.construction_end_date
|
||||
? `${sb.construction_start_date} ~ ${sb.construction_end_date}`
|
||||
: '';
|
||||
|
||||
const siteBriefing: SiteBriefingInfo = sb
|
||||
? {
|
||||
briefingCode: sb.briefing_code || '',
|
||||
partnerName: sb.partner?.name || '',
|
||||
companyName: sb.partner?.name || '',
|
||||
briefingDate: sb.briefing_date || '',
|
||||
attendee: attendeeNames,
|
||||
}
|
||||
: { briefingCode: '', partnerName: '', companyName: '', briefingDate: '', attendee: '' };
|
||||
|
||||
const bidInfo: BidInfo = {
|
||||
projectName: sb?.title || apiData.site_name || '',
|
||||
bidDate: sb?.bid_date || apiData.registration_date || '',
|
||||
siteCount: sb?.site_count || 0,
|
||||
constructionPeriod,
|
||||
constructionStartDate: sb?.construction_start_date || '',
|
||||
constructionEndDate: sb?.construction_end_date || '',
|
||||
vatType: (sb?.vat_type as 'excluded' | 'included') || 'excluded',
|
||||
workReport: sb?.description || '',
|
||||
documents: [],
|
||||
};
|
||||
|
||||
// options에서 데이터 변환
|
||||
const opts = apiData.options;
|
||||
|
||||
const summaryItems: EstimateSummaryItem[] = (opts?.summary_items || []).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
materialCost: item.material_cost,
|
||||
laborCost: item.labor_cost,
|
||||
totalCost: item.total_cost,
|
||||
remarks: item.remarks || '',
|
||||
}));
|
||||
|
||||
const expenseItems: ExpenseItem[] = (opts?.expense_items || []).map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
amount: item.amount,
|
||||
}));
|
||||
|
||||
const priceAdjustments: PriceAdjustmentItem[] = (opts?.price_adjustments || []).map((item) => ({
|
||||
id: item.id,
|
||||
category: item.category,
|
||||
unitPrice: item.unit_price,
|
||||
coating: item.coating,
|
||||
batting: item.batting,
|
||||
boxReinforce: item.box_reinforce,
|
||||
painting: item.painting,
|
||||
total: item.total,
|
||||
}));
|
||||
|
||||
const detailItems: EstimateDetailItem[] = (apiData.items || []).map((item, index) => ({
|
||||
id: String(item.id),
|
||||
no: index + 1,
|
||||
name: item.item_name || '',
|
||||
material: item.specification || '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
quantity: item.calculated_quantity || 0,
|
||||
box: 0,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 0,
|
||||
laborCost: 0,
|
||||
quantityPrice: item.unit_price || 0,
|
||||
expenseQuantity: 0,
|
||||
expenseTotal: 0,
|
||||
totalCost: item.total_price || 0,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: item.total_price || 0,
|
||||
unitPrice: item.unit_price || 0,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: item.base_quantity || 0,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
...base,
|
||||
siteBriefing,
|
||||
bidInfo,
|
||||
summaryItems,
|
||||
expenseItems,
|
||||
priceAdjustments,
|
||||
detailItems,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EstimateDetailFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<EstimateDetailFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.estimateCode !== undefined) apiData.quote_number = data.estimateCode;
|
||||
if (data.estimatorId !== undefined) apiData.created_by = data.estimatorId || null;
|
||||
if (data.estimatorName !== undefined) apiData.author = data.estimatorName || null;
|
||||
if (data.estimateAmount !== undefined) apiData.total_amount = data.estimateAmount;
|
||||
if (data.status !== undefined) {
|
||||
// Estimate 상태 → Quote 상태 역매핑
|
||||
const reverseStatusMap: Record<string, string> = {
|
||||
pending: 'pending',
|
||||
approval_waiting: 'sent',
|
||||
completed: 'finalized',
|
||||
rejected: 'rejected',
|
||||
hold: 'draft',
|
||||
};
|
||||
apiData.status = reverseStatusMap[data.status] || 'pending';
|
||||
}
|
||||
|
||||
if (data.bidInfo !== undefined) {
|
||||
apiData.site_name = data.bidInfo.projectName;
|
||||
apiData.registration_date = data.bidInfo.bidDate;
|
||||
}
|
||||
|
||||
// options 데이터 역변환
|
||||
const options: Record<string, unknown> = {};
|
||||
|
||||
if (data.summaryItems !== undefined) {
|
||||
options.summary_items = data.summaryItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
material_cost: item.materialCost,
|
||||
labor_cost: item.laborCost,
|
||||
total_cost: item.totalCost,
|
||||
remarks: item.remarks,
|
||||
}));
|
||||
}
|
||||
|
||||
if (data.expenseItems !== undefined) {
|
||||
options.expense_items = data.expenseItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
amount: item.amount,
|
||||
}));
|
||||
}
|
||||
|
||||
if (data.priceAdjustments !== undefined) {
|
||||
options.price_adjustments = data.priceAdjustments.map((item) => ({
|
||||
id: item.id,
|
||||
category: item.category,
|
||||
unit_price: item.unitPrice,
|
||||
coating: item.coating,
|
||||
batting: item.batting,
|
||||
box_reinforce: item.boxReinforce,
|
||||
painting: item.painting,
|
||||
total: item.total,
|
||||
}));
|
||||
}
|
||||
|
||||
if (Object.keys(options).length > 0) {
|
||||
apiData.options = options;
|
||||
}
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수 (quotes API 사용)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 건설 견적 목록 조회
|
||||
* GET /api/v1/quotes?quote_type=construction
|
||||
*/
|
||||
export async function getEstimateList(filter?: EstimateFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: EstimateListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let filtered = [...mockEstimates];
|
||||
const queryParams: Record<string, string> = {
|
||||
quote_type: 'construction', // 건설 견적만 조회
|
||||
};
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(e) =>
|
||||
e.projectName.toLowerCase().includes(search) ||
|
||||
e.estimateCode.toLowerCase().includes(search) ||
|
||||
e.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
// 검색
|
||||
if (filter?.search) queryParams.q = filter.search;
|
||||
|
||||
// 상태 필터
|
||||
// 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filtered = filtered.filter((e) => e.status === filter.status);
|
||||
// Estimate 상태 → Quote 상태로 변환
|
||||
const statusMap: Record<string, string> = {
|
||||
pending: 'pending',
|
||||
approval_waiting: 'sent',
|
||||
completed: 'finalized',
|
||||
rejected: 'rejected',
|
||||
hold: 'draft',
|
||||
};
|
||||
queryParams.status = statusMap[filter.status] || filter.status;
|
||||
}
|
||||
if (filter?.partnerId) queryParams.client_id = filter.partnerId;
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId) {
|
||||
filtered = filtered.filter((e) => e.partnerId === filter.partnerId);
|
||||
}
|
||||
// 날짜 범위
|
||||
if (filter?.startDate) queryParams.date_from = filter.startDate;
|
||||
if (filter?.endDate) queryParams.date_to = filter.endDate;
|
||||
|
||||
// 견적자 필터
|
||||
if (filter?.estimatorId) {
|
||||
filtered = filtered.filter((e) => e.estimatorId === filter.estimatorId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filtered = filtered.filter((e) => e.createdAt >= filter.startDate!);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filtered = filtered.filter((e) => e.createdAt <= filter.endDate!);
|
||||
}
|
||||
// 페이지네이션
|
||||
if (filter?.page) queryParams.page = String(filter.page);
|
||||
if (filter?.size) queryParams.size = String(filter.size);
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'amountDesc':
|
||||
filtered.sort((a, b) => b.estimateAmount - a.estimateAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
filtered.sort((a, b) => a.estimateAmount - b.estimateAmount);
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
filtered.sort((a, b) => {
|
||||
if (!a.bidDate) return 1;
|
||||
if (!b.bidDate) return -1;
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
latest: { field: 'created_at', dir: 'desc' },
|
||||
oldest: { field: 'created_at', dir: 'asc' },
|
||||
amountDesc: { field: 'total_amount', dir: 'desc' },
|
||||
amountAsc: { field: 'total_amount', dir: 'asc' },
|
||||
bidDateDesc: { field: 'registration_date', dir: 'desc' },
|
||||
partnerNameAsc: { field: 'client_name', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'client_name', dir: 'desc' },
|
||||
projectNameAsc: { field: 'site_name', dir: 'asc' },
|
||||
projectNameDesc: { field: 'site_name', dir: 'desc' },
|
||||
};
|
||||
const sort = sortMap[filter.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_order = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: {
|
||||
data: ApiQuote[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
};
|
||||
}>('/quotes', { params: queryParams });
|
||||
|
||||
const paginatedData = response.data;
|
||||
const items = (paginatedData.data || []).map(transformQuoteToEstimate);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
items,
|
||||
total: paginatedData.total || 0,
|
||||
page: paginatedData.current_page || 1,
|
||||
size: paginatedData.per_page || 20,
|
||||
totalPages: paginatedData.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getEstimateList error:', error);
|
||||
return { success: false, error: '견적 목록 조회에 실패했습니다.' };
|
||||
console.error('견적 목록 조회 오류:', error);
|
||||
return { success: false, error: '견적 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 상세 조회
|
||||
export async function getEstimate(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: Estimate; error?: string }> {
|
||||
/**
|
||||
* 견적 단건 조회
|
||||
* GET /api/v1/quotes/{id}
|
||||
*/
|
||||
export async function getEstimate(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Estimate;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const estimate = mockEstimates.find((e) => e.id === id);
|
||||
|
||||
if (!estimate) {
|
||||
return { success: false, error: '견적을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: estimate };
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiQuote }>(`/quotes/${id}`);
|
||||
return { success: true, data: transformQuoteToEstimate(response.data) };
|
||||
} catch (error) {
|
||||
console.error('getEstimate error:', error);
|
||||
return { success: false, error: '견적 조회에 실패했습니다.' };
|
||||
console.error('견적 조회 오류:', error);
|
||||
return { success: false, error: '견적 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 통계 조회
|
||||
export async function getEstimateStats(): Promise<{ success: boolean; data?: EstimateStats; error?: string }> {
|
||||
/**
|
||||
* 견적 상세 조회 (첨부 정보 포함)
|
||||
* GET /api/v1/quotes/{id}
|
||||
*/
|
||||
export async function getEstimateDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: EstimateDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const total = mockEstimates.length;
|
||||
const pending = mockEstimates.filter((e) => e.status === 'pending').length;
|
||||
const completed = mockEstimates.filter((e) => e.status === 'completed').length;
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiQuote }>(`/quotes/${id}`);
|
||||
return { success: true, data: transformQuoteToEstimateDetail(response.data) };
|
||||
} catch (error) {
|
||||
console.error('견적 상세 조회 오류:', error);
|
||||
return { success: false, error: '견적 상세 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 통계 조회
|
||||
* GET /api/v1/quotes/stats (건설용)
|
||||
* 현재는 목록 조회로 대체
|
||||
*/
|
||||
export async function getEstimateStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: EstimateStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 통계 API가 없으므로 목록 조회로 대체
|
||||
const [allResponse, pendingResponse] = await Promise.all([
|
||||
apiClient.get<{ success: boolean; data: { total: number } }>('/quotes', {
|
||||
params: { quote_type: 'construction', size: '1' },
|
||||
}),
|
||||
apiClient.get<{ success: boolean; data: { total: number } }>('/quotes', {
|
||||
params: { quote_type: 'construction', status: 'pending', size: '1' },
|
||||
}),
|
||||
]);
|
||||
|
||||
const total = allResponse.data?.total || 0;
|
||||
const pending = pendingResponse.data?.total || 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
pending,
|
||||
completed,
|
||||
completed: total - pending,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getEstimateStats error:', error);
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
console.error('견적 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 삭제
|
||||
export async function deleteEstimate(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
/**
|
||||
* 견적 등록
|
||||
* POST /api/v1/quotes
|
||||
*/
|
||||
export async function createEstimate(data: EstimateDetailFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Estimate;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
console.log('Delete estimate:', id);
|
||||
const apiData = {
|
||||
...transformToApiRequest(data),
|
||||
quote_type: 'construction', // 건설 견적으로 생성
|
||||
};
|
||||
const response = await apiClient.post<ApiQuote>('/quotes', apiData);
|
||||
return { success: true, data: transformQuoteToEstimate(response) };
|
||||
} catch (error) {
|
||||
console.error('견적 등록 오류:', error);
|
||||
return { success: false, error: '견적 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 수정
|
||||
* PUT /api/v1/quotes/{id}
|
||||
*/
|
||||
export async function updateEstimate(
|
||||
id: string,
|
||||
data: Partial<EstimateDetailFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Estimate;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiQuote>(`/quotes/${id}`, apiData);
|
||||
return { success: true, data: transformQuoteToEstimate(response) };
|
||||
} catch (error) {
|
||||
console.error('견적 수정 오류:', error);
|
||||
return { success: false, error: '견적 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 삭제
|
||||
* DELETE /api/v1/quotes/{id}
|
||||
*/
|
||||
export async function deleteEstimate(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.delete(`/quotes/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteEstimate error:', error);
|
||||
console.error('견적 삭제 오류:', error);
|
||||
return { success: false, error: '견적 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 견적 일괄 삭제
|
||||
export async function deleteEstimates(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
/**
|
||||
* 견적 일괄 삭제
|
||||
* DELETE /api/v1/quotes/bulk
|
||||
*/
|
||||
export async function deleteEstimates(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
console.log('Delete estimates:', ids);
|
||||
await apiClient.delete('/quotes/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteEstimates error:', error);
|
||||
console.error('견적 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -1,400 +1,452 @@
|
||||
'use server';
|
||||
|
||||
import type { HandoverReport, HandoverReportStats, HandoverReportDetail, HandoverReportFormData } from './types';
|
||||
import type {
|
||||
HandoverReport,
|
||||
HandoverReportDetail,
|
||||
HandoverReportStats,
|
||||
HandoverReportFormData,
|
||||
ConstructionManager,
|
||||
ContractItem,
|
||||
ExternalEquipmentCost,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_REPORTS: HandoverReport[] = [
|
||||
{
|
||||
id: '1',
|
||||
reportNumber: '123123',
|
||||
partnerName: '통신공사',
|
||||
siteName: '서울역사 통신공사',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: '김PM',
|
||||
totalSites: 21,
|
||||
contractAmount: 105800000,
|
||||
contractStartDate: '2025-12-12',
|
||||
contractEndDate: '2026-12-12',
|
||||
status: 'pending',
|
||||
contractId: '1',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
reportNumber: '123124',
|
||||
partnerName: '야사건설',
|
||||
siteName: '부산항 건설현장',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMName: '이PM',
|
||||
totalSites: 15,
|
||||
contractAmount: 10500000,
|
||||
contractStartDate: '2025-11-01',
|
||||
contractEndDate: '2026-11-01',
|
||||
status: 'completed',
|
||||
contractId: '2',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
reportNumber: '123125',
|
||||
partnerName: '여의건설',
|
||||
siteName: '인천공항 확장공사',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMName: '박PM',
|
||||
totalSites: 30,
|
||||
contractAmount: 10000000,
|
||||
contractStartDate: '2025-10-15',
|
||||
contractEndDate: '2026-10-15',
|
||||
status: 'pending',
|
||||
contractId: '3',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
reportNumber: '123126',
|
||||
partnerName: '통신공사',
|
||||
siteName: '대전역 리모델링',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: '김PM',
|
||||
totalSites: 18,
|
||||
contractAmount: 10000000,
|
||||
contractStartDate: '2025-09-20',
|
||||
contractEndDate: '2026-03-20',
|
||||
status: 'completed',
|
||||
contractId: '4',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
reportNumber: '123127',
|
||||
partnerName: '야사건설',
|
||||
siteName: '광주 신축현장',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMName: '이PM',
|
||||
totalSites: 17,
|
||||
contractAmount: 10500000,
|
||||
contractStartDate: '2025-08-01',
|
||||
contractEndDate: '2026-08-01',
|
||||
status: 'pending',
|
||||
contractId: '5',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
reportNumber: '123128',
|
||||
partnerName: '여의건설',
|
||||
siteName: '세종시 행정타운',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMName: '박PM',
|
||||
totalSites: 25,
|
||||
contractAmount: 100000000,
|
||||
contractStartDate: '2025-07-15',
|
||||
contractEndDate: '2026-07-15',
|
||||
status: 'completed',
|
||||
contractId: '6',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
reportNumber: '123129',
|
||||
partnerName: '통신공사',
|
||||
siteName: '제주 관광단지',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: null,
|
||||
totalSites: 12,
|
||||
contractAmount: 105800000,
|
||||
contractStartDate: '2025-06-01',
|
||||
contractEndDate: '2026-06-01',
|
||||
status: 'pending',
|
||||
contractId: '7',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 인수인계보고서관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
interface GetHandoverReportListParams {
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiHandoverReport {
|
||||
id: number;
|
||||
report_number: string;
|
||||
partner_name: string | null;
|
||||
site_name: string;
|
||||
contract_manager_name: string | null;
|
||||
construction_pm_name: string | null;
|
||||
construction_pm_id: number | null;
|
||||
total_sites: number;
|
||||
contract_amount: number;
|
||||
contract_date: string | null;
|
||||
contract_start_date: string | null;
|
||||
contract_end_date: string | null;
|
||||
completion_date: string | null;
|
||||
status: 'pending' | 'completed';
|
||||
contract_id: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// 상세 조회 시 포함
|
||||
managers?: ApiManager[];
|
||||
items?: ApiContractItem[];
|
||||
has_secondary_piping?: boolean;
|
||||
secondary_piping_amount?: number;
|
||||
secondary_piping_note?: string | null;
|
||||
has_coating?: boolean;
|
||||
coating_amount?: number;
|
||||
coating_note?: string | null;
|
||||
external_equipment_cost?: ApiExternalEquipmentCost;
|
||||
special_notes?: string | null;
|
||||
}
|
||||
|
||||
interface ApiManager {
|
||||
id: number;
|
||||
name: string;
|
||||
non_performance_reason: string | null;
|
||||
signature: string | null;
|
||||
}
|
||||
|
||||
interface ApiContractItem {
|
||||
id: number;
|
||||
item_no: number;
|
||||
name: string;
|
||||
product: string | null;
|
||||
quantity: number;
|
||||
remark: string | null;
|
||||
}
|
||||
|
||||
interface ApiExternalEquipmentCost {
|
||||
shipping_cost: number;
|
||||
high_altitude_work: number;
|
||||
public_expense: number;
|
||||
}
|
||||
|
||||
interface ApiHandoverReportStats {
|
||||
total_count: number;
|
||||
pending_count: number;
|
||||
completed_count: number;
|
||||
total_amount?: number;
|
||||
total_sites?: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → HandoverReport 타입 변환 (목록용)
|
||||
*/
|
||||
function transformHandoverReport(apiData: ApiHandoverReport): HandoverReport {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
reportNumber: apiData.report_number || '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
siteName: apiData.site_name || '',
|
||||
contractManagerName: apiData.contract_manager_name || '',
|
||||
constructionPMName: apiData.construction_pm_name || null,
|
||||
totalSites: apiData.total_sites || 0,
|
||||
contractAmount: apiData.contract_amount || 0,
|
||||
contractStartDate: apiData.contract_start_date || null,
|
||||
contractEndDate: apiData.contract_end_date || null,
|
||||
status: apiData.status || 'pending',
|
||||
contractId: apiData.contract_id ? String(apiData.contract_id) : '',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → HandoverReportDetail 타입 변환 (상세용)
|
||||
*/
|
||||
function transformHandoverReportDetail(apiData: ApiHandoverReport): HandoverReportDetail {
|
||||
// 공사담당자 목록 변환
|
||||
const constructionManagers: ConstructionManager[] = (apiData.managers || []).map((m) => ({
|
||||
id: String(m.id),
|
||||
name: m.name || '',
|
||||
nonPerformanceReason: m.non_performance_reason || '',
|
||||
signature: m.signature || null,
|
||||
}));
|
||||
|
||||
// 계약 ITEM 목록 변환
|
||||
const contractItems: ContractItem[] = (apiData.items || []).map((item) => ({
|
||||
id: String(item.id),
|
||||
no: item.item_no || 0,
|
||||
name: item.name || '',
|
||||
product: item.product || '',
|
||||
quantity: item.quantity || 0,
|
||||
remark: item.remark || '',
|
||||
}));
|
||||
|
||||
// 장비 외 실행금액 변환
|
||||
const externalCost = apiData.external_equipment_cost;
|
||||
const externalEquipmentCost: ExternalEquipmentCost = externalCost
|
||||
? {
|
||||
shippingCost: externalCost.shipping_cost || 0,
|
||||
highAltitudeWork: externalCost.high_altitude_work || 0,
|
||||
publicExpense: externalCost.public_expense || 0,
|
||||
}
|
||||
: {
|
||||
shippingCost: 0,
|
||||
highAltitudeWork: 0,
|
||||
publicExpense: 0,
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
reportNumber: apiData.report_number || '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
siteName: apiData.site_name || '',
|
||||
contractManagerName: apiData.contract_manager_name || '',
|
||||
constructionPMName: apiData.construction_pm_name || null,
|
||||
constructionPMId: apiData.construction_pm_id ? String(apiData.construction_pm_id) : null,
|
||||
totalSites: apiData.total_sites || 0,
|
||||
contractAmount: apiData.contract_amount || 0,
|
||||
contractDate: apiData.contract_date || null,
|
||||
contractStartDate: apiData.contract_start_date || null,
|
||||
contractEndDate: apiData.contract_end_date || null,
|
||||
completionDate: apiData.completion_date || null,
|
||||
status: apiData.status || 'pending',
|
||||
contractId: apiData.contract_id ? String(apiData.contract_id) : '',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
constructionManagers,
|
||||
contractItems,
|
||||
hasSecondaryPiping: apiData.has_secondary_piping || false,
|
||||
secondaryPipingAmount: apiData.secondary_piping_amount || 0,
|
||||
secondaryPipingNote: apiData.secondary_piping_note || '',
|
||||
hasCoating: apiData.has_coating || false,
|
||||
coatingAmount: apiData.coating_amount || 0,
|
||||
coatingNote: apiData.coating_note || '',
|
||||
externalEquipmentCost,
|
||||
specialNotes: apiData.special_notes || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* HandoverReportFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<HandoverReportFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.reportNumber !== undefined) apiData.report_number = data.reportNumber;
|
||||
if (data.partnerName !== undefined) apiData.partner_name = data.partnerName || null;
|
||||
if (data.siteName !== undefined) apiData.site_name = data.siteName;
|
||||
if (data.contractManagerName !== undefined) apiData.contract_manager_name = data.contractManagerName || null;
|
||||
if (data.contractDate !== undefined) apiData.contract_date = data.contractDate || null;
|
||||
if (data.totalSites !== undefined) apiData.total_sites = data.totalSites;
|
||||
if (data.contractStartDate !== undefined) apiData.contract_start_date = data.contractStartDate || null;
|
||||
if (data.contractEndDate !== undefined) apiData.contract_end_date = data.contractEndDate || null;
|
||||
if (data.contractAmount !== undefined) apiData.contract_amount = data.contractAmount;
|
||||
if (data.constructionPMId !== undefined) apiData.construction_pm_id = data.constructionPMId || null;
|
||||
if (data.constructionPMName !== undefined) apiData.construction_pm_name = data.constructionPMName || null;
|
||||
if (data.status !== undefined) apiData.status = data.status;
|
||||
if (data.hasSecondaryPiping !== undefined) apiData.has_secondary_piping = data.hasSecondaryPiping;
|
||||
if (data.secondaryPipingNote !== undefined) apiData.secondary_piping_note = data.secondaryPipingNote || null;
|
||||
if (data.hasCoating !== undefined) apiData.has_coating = data.hasCoating;
|
||||
if (data.coatingNote !== undefined) apiData.coating_note = data.coatingNote || null;
|
||||
if (data.specialNotes !== undefined) apiData.special_notes = data.specialNotes || null;
|
||||
|
||||
// 장비 외 실행금액 변환
|
||||
if (data.externalEquipmentCost !== undefined) {
|
||||
apiData.external_equipment_cost = {
|
||||
shipping_cost: data.externalEquipmentCost.shippingCost,
|
||||
high_altitude_work: data.externalEquipmentCost.highAltitudeWork,
|
||||
public_expense: data.externalEquipmentCost.publicExpense,
|
||||
};
|
||||
}
|
||||
|
||||
// 공사담당자 변환
|
||||
if (data.constructionManagers !== undefined) {
|
||||
apiData.managers = data.constructionManagers.map((m) => ({
|
||||
name: m.name,
|
||||
non_performance_reason: m.nonPerformanceReason || null,
|
||||
signature: m.signature || null,
|
||||
}));
|
||||
}
|
||||
|
||||
// 계약 ITEM 변환
|
||||
if (data.contractItems !== undefined) {
|
||||
apiData.items = data.contractItems.map((item, index) => ({
|
||||
item_no: item.no || index + 1,
|
||||
name: item.name,
|
||||
product: item.product || null,
|
||||
quantity: item.quantity,
|
||||
remark: item.remark || null,
|
||||
}));
|
||||
}
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 인수인계보고서 목록 조회
|
||||
* GET /api/v1/construction/handover-reports
|
||||
*/
|
||||
export async function getHandoverReportList(params?: {
|
||||
size?: number;
|
||||
page?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
interface GetHandoverReportListResult {
|
||||
search?: string;
|
||||
status?: string;
|
||||
partnerId?: string;
|
||||
contractManagerId?: string;
|
||||
constructionPMId?: string;
|
||||
sortBy?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
items: HandoverReport[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getHandoverReportList(
|
||||
params: GetHandoverReportListParams = {}
|
||||
): Promise<GetHandoverReportListResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
// const response = await fetch(`/api/v1/handover-reports?...`);
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 페이지네이션
|
||||
if (params?.page) queryParams.page = String(params.page);
|
||||
if (params?.size) queryParams.per_page = String(params.size);
|
||||
|
||||
// 검색
|
||||
if (params?.search) queryParams.search = params.search;
|
||||
|
||||
// 필터
|
||||
if (params?.status && params.status !== 'all') queryParams.status = params.status;
|
||||
if (params?.partnerId && params.partnerId !== 'all') queryParams.partner_id = params.partnerId;
|
||||
if (params?.contractManagerId && params.contractManagerId !== 'all') {
|
||||
queryParams.contract_manager_id = params.contractManagerId;
|
||||
}
|
||||
if (params?.constructionPMId && params.constructionPMId !== 'all') {
|
||||
queryParams.construction_pm_id = params.constructionPMId;
|
||||
}
|
||||
|
||||
// 날짜 범위
|
||||
if (params?.startDate) queryParams.start_date = params.startDate;
|
||||
if (params?.endDate) queryParams.end_date = params.endDate;
|
||||
|
||||
// 정렬
|
||||
if (params?.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
contractDateDesc: { field: 'contract_start_date', dir: 'desc' },
|
||||
contractDateAsc: { field: 'contract_start_date', dir: 'asc' },
|
||||
partnerNameAsc: { field: 'partner_name', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'partner_name', dir: 'desc' },
|
||||
siteNameAsc: { field: 'site_name', dir: 'asc' },
|
||||
siteNameDesc: { field: 'site_name', dir: 'desc' },
|
||||
};
|
||||
const sort = sortMap[params.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiHandoverReport[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/construction/handover-reports', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformHandoverReport);
|
||||
|
||||
// 목업 데이터 반환
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: MOCK_REPORTS,
|
||||
total: MOCK_REPORTS.length,
|
||||
page: params.page || 1,
|
||||
size: params.size || 20,
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch handover report list:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '인수인계보고서 목록을 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 목록 조회 오류:', error);
|
||||
return { success: false, error: '인수인계보고서 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
interface GetHandoverReportStatsResult {
|
||||
/**
|
||||
* 인수인계보고서 통계 조회
|
||||
* GET /api/v1/construction/handover-reports/stats
|
||||
*/
|
||||
export async function getHandoverReportStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: HandoverReportStats;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getHandoverReportStats(): Promise<GetHandoverReportStatsResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
|
||||
// 목업 통계 반환
|
||||
const pending = MOCK_REPORTS.filter(r => r.status === 'pending').length;
|
||||
const completed = MOCK_REPORTS.filter(r => r.status === 'completed').length;
|
||||
const response = await apiClient.get<ApiHandoverReportStats>('/construction/handover-reports/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: MOCK_REPORTS.length,
|
||||
pending,
|
||||
completed,
|
||||
total: response.total_count || 0,
|
||||
pending: response.pending_count || 0,
|
||||
completed: response.completed_count || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch handover report stats:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '통계를 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
interface DeleteHandoverReportResult {
|
||||
/**
|
||||
* 인수인계보고서 삭제
|
||||
* DELETE /api/v1/construction/handover-reports/{id}
|
||||
*/
|
||||
export async function deleteHandoverReport(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function deleteHandoverReport(id: string): Promise<DeleteHandoverReportResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
console.log('Deleting handover report:', id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
await apiClient.delete(`/construction/handover-reports/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete handover report:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '삭제에 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 삭제 오류:', error);
|
||||
return { success: false, error: '삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
interface DeleteHandoverReportsResult {
|
||||
/**
|
||||
* 인수인계보고서 일괄 삭제
|
||||
* DELETE /api/v1/construction/handover-reports/bulk
|
||||
*/
|
||||
export async function deleteHandoverReports(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function deleteHandoverReports(ids: string[]): Promise<DeleteHandoverReportsResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
console.log('Deleting handover reports:', ids);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: ids.length,
|
||||
};
|
||||
await apiClient.delete('/construction/handover-reports/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('Failed to delete handover reports:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '일괄 삭제에 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 목업 상세 데이터
|
||||
const MOCK_REPORT_DETAILS: Record<string, HandoverReportDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
reportNumber: '123123',
|
||||
partnerName: '통신공사',
|
||||
siteName: '서울역사 통신공사',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: '김PM',
|
||||
constructionPMId: 'pm1',
|
||||
totalSites: 21,
|
||||
contractAmount: 105800000,
|
||||
contractDate: '2025-12-12',
|
||||
contractStartDate: '2026-01-01',
|
||||
contractEndDate: '2026-12-10',
|
||||
status: 'pending',
|
||||
contractId: '1',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
completionDate: '2026-05-01',
|
||||
constructionManagers: [
|
||||
{ id: 'mgr1', name: '홍길동', isNonPerformanceUsed: false },
|
||||
{ id: 'mgr2', name: '김철수', isNonPerformanceUsed: true },
|
||||
],
|
||||
contractItems: [
|
||||
{ id: 'item1', no: 1, name: '접지방화서터', product: '제품', quantity: 1000, remark: '품질인증적용' },
|
||||
{ id: 'item2', no: 2, name: '스크린방화서터', product: '제품', quantity: 111, remark: '품질인증적용' },
|
||||
],
|
||||
hasSecondaryPiping: true,
|
||||
secondaryPipingAmount: 1200000,
|
||||
hasCoating: true,
|
||||
coatingAmount: 500000,
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 1500000,
|
||||
highAltitudeWork: 800000,
|
||||
publicExpense: 10000000,
|
||||
},
|
||||
specialNotes: '특이사항 내용이 여기에 표시됩니다.',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
reportNumber: '123124',
|
||||
partnerName: '야사건설',
|
||||
siteName: '부산항 건설현장',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMName: '이PM',
|
||||
constructionPMId: 'pm2',
|
||||
totalSites: 15,
|
||||
contractAmount: 10500000,
|
||||
contractDate: '2025-11-01',
|
||||
contractStartDate: '2025-11-01',
|
||||
contractEndDate: '2026-11-01',
|
||||
status: 'completed',
|
||||
contractId: '2',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
completionDate: '2026-04-01',
|
||||
constructionManagers: [
|
||||
{ id: 'mgr3', name: '이영희', isNonPerformanceUsed: false },
|
||||
],
|
||||
contractItems: [
|
||||
{ id: 'item3', no: 1, name: '방화문', product: '제품A', quantity: 500, remark: '' },
|
||||
],
|
||||
hasSecondaryPiping: false,
|
||||
secondaryPipingAmount: 0,
|
||||
hasCoating: false,
|
||||
coatingAmount: 0,
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 500000,
|
||||
highAltitudeWork: 0,
|
||||
publicExpense: 2000000,
|
||||
},
|
||||
specialNotes: '',
|
||||
},
|
||||
};
|
||||
|
||||
interface GetHandoverReportDetailResult {
|
||||
/**
|
||||
* 인수인계보고서 상세 조회
|
||||
* GET /api/v1/construction/handover-reports/{id}
|
||||
*/
|
||||
export async function getHandoverReportDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: HandoverReportDetail;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getHandoverReportDetail(id: string): Promise<GetHandoverReportDetailResult> {
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
// const response = await fetch(`/api/v1/handover-reports/${id}`);
|
||||
|
||||
const detail = MOCK_REPORT_DETAILS[id];
|
||||
|
||||
if (!detail) {
|
||||
// 목록 데이터에서 기본 상세 생성
|
||||
const report = MOCK_REPORTS.find(r => r.id === id);
|
||||
if (report) {
|
||||
const generatedDetail: HandoverReportDetail = {
|
||||
...report,
|
||||
contractDate: report.contractStartDate,
|
||||
constructionPMId: 'pm1',
|
||||
completionDate: null,
|
||||
constructionManagers: [],
|
||||
contractItems: [],
|
||||
hasSecondaryPiping: false,
|
||||
secondaryPipingAmount: 0,
|
||||
hasCoating: false,
|
||||
coatingAmount: 0,
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 0,
|
||||
highAltitudeWork: 0,
|
||||
publicExpense: 0,
|
||||
},
|
||||
specialNotes: '',
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: generatedDetail,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: '인수인계보고서를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: detail,
|
||||
};
|
||||
const response = await apiClient.get<ApiHandoverReport>(`/construction/handover-reports/${id}`);
|
||||
return { success: true, data: transformHandoverReportDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch handover report detail:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '인수인계보고서 상세 정보를 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 상세 조회 오류:', error);
|
||||
return { success: false, error: '인수인계보고서를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
interface UpdateHandoverReportResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 인수인계보고서 수정
|
||||
* PUT /api/v1/construction/handover-reports/{id}
|
||||
*/
|
||||
export async function updateHandoverReport(
|
||||
id: string,
|
||||
data: HandoverReportFormData
|
||||
): Promise<UpdateHandoverReportResult> {
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: HandoverReportDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
console.log('Updating handover report:', id, data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiHandoverReport>(`/construction/handover-reports/${id}`, apiData);
|
||||
return { success: true, data: transformHandoverReportDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('Failed to update handover report:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '수정에 실패했습니다.',
|
||||
};
|
||||
console.error('인수인계보고서 수정 오류:', error);
|
||||
return { success: false, error: '수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 인수인계보고서 등록
|
||||
* POST /api/v1/construction/handover-reports
|
||||
*/
|
||||
export async function createHandoverReport(
|
||||
data: HandoverReportFormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: HandoverReportDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.post<ApiHandoverReport>('/construction/handover-reports', apiData);
|
||||
return { success: true, data: transformHandoverReportDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('인수인계보고서 등록 오류:', error);
|
||||
return { success: false, error: '등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -1,187 +1,250 @@
|
||||
'use server';
|
||||
|
||||
import type { Item, ItemStats, ItemListParams, ItemListResponse, ItemDetail, ItemFormData, OrderItem } from './types';
|
||||
import type { Item, ItemStats, ItemListParams, ItemListResponse, ItemDetail, ItemFormData, OrderItem, ItemType, Specification, OrderType as FrontOrderType, ItemStatus } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목데이터
|
||||
const mockItems: Item[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemNumber: '123123',
|
||||
itemType: '제품',
|
||||
categoryId: '1',
|
||||
categoryName: '카테고리명',
|
||||
itemName: '품목명',
|
||||
specification: '인정',
|
||||
unit: 'SET',
|
||||
orderType: '외주발주',
|
||||
status: '승인',
|
||||
createdAt: '2026-01-01T10:00:00Z',
|
||||
updatedAt: '2026-01-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemNumber: '123124',
|
||||
itemType: '부품',
|
||||
categoryId: '2',
|
||||
categoryName: '모터',
|
||||
itemName: '소형 모터 A',
|
||||
specification: '비인정',
|
||||
unit: 'SET',
|
||||
orderType: '외주발주',
|
||||
status: '승인',
|
||||
createdAt: '2026-01-02T11:00:00Z',
|
||||
updatedAt: '2026-01-02T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemNumber: '123125',
|
||||
itemType: '소모품',
|
||||
categoryId: '3',
|
||||
categoryName: '공정자재',
|
||||
itemName: '절연테이프',
|
||||
specification: '인정',
|
||||
unit: 'SET',
|
||||
orderType: '외주발주',
|
||||
status: '승인',
|
||||
createdAt: '2026-01-03T09:00:00Z',
|
||||
updatedAt: '2026-01-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemNumber: '123126',
|
||||
itemType: '공과',
|
||||
categoryId: '4',
|
||||
categoryName: '철물',
|
||||
itemName: '볼트 세트',
|
||||
specification: '비인정',
|
||||
unit: 'EA',
|
||||
orderType: '경품발주',
|
||||
status: '작업',
|
||||
createdAt: '2026-01-03T10:00:00Z',
|
||||
updatedAt: '2026-01-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemNumber: '123127',
|
||||
itemType: '부품',
|
||||
categoryId: '1',
|
||||
categoryName: '슬라이드 OPEN 사이즈',
|
||||
itemName: '슬라이드 레일',
|
||||
specification: '인정',
|
||||
unit: 'EA',
|
||||
orderType: '원자재발주',
|
||||
status: '작업',
|
||||
createdAt: '2026-01-04T08:00:00Z',
|
||||
updatedAt: '2026-01-04T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
itemNumber: '123128',
|
||||
itemType: '소모품',
|
||||
categoryId: '3',
|
||||
categoryName: '공정자재',
|
||||
itemName: '윤활유',
|
||||
specification: '비인정',
|
||||
unit: 'L',
|
||||
orderType: '외주발주',
|
||||
status: '사용',
|
||||
createdAt: '2026-01-04T09:00:00Z',
|
||||
updatedAt: '2026-01-04T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
itemNumber: '123129',
|
||||
itemType: '소모품',
|
||||
categoryId: '3',
|
||||
categoryName: '공정자재',
|
||||
itemName: '포장재',
|
||||
specification: '인정',
|
||||
unit: 'BOX',
|
||||
orderType: '경품발주',
|
||||
status: '중지',
|
||||
createdAt: '2026-01-05T10:00:00Z',
|
||||
updatedAt: '2026-01-05T10:00:00Z',
|
||||
},
|
||||
];
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
// 품목 목록 조회
|
||||
/**
|
||||
* Backend item_type → Frontend itemType 변환
|
||||
* FG → 제품, PT → 부품, SM → 소모품, CS → 소모품, RM → 공과
|
||||
*/
|
||||
function transformItemType(backendType: string | null | undefined): ItemType {
|
||||
const typeMap: Record<string, ItemType> = {
|
||||
FG: '제품',
|
||||
PT: '부품',
|
||||
SM: '소모품',
|
||||
CS: '소모품',
|
||||
RM: '공과',
|
||||
};
|
||||
return typeMap[backendType?.toUpperCase() || ''] || '제품';
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend itemType → Backend item_type 변환
|
||||
* 제품 → FG, 부품 → PT, 소모품 → CS, 공과 → RM
|
||||
*/
|
||||
function transformToBackendItemType(frontendType: ItemType): string {
|
||||
const typeMap: Record<ItemType, string> = {
|
||||
'제품': 'FG',
|
||||
'부품': 'PT',
|
||||
'소모품': 'CS',
|
||||
'공과': 'RM',
|
||||
};
|
||||
return typeMap[frontendType] || 'FG';
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend options → Frontend specification 변환
|
||||
*/
|
||||
function transformSpecification(options: Record<string, unknown> | null | undefined): Specification {
|
||||
const spec = options?.specification;
|
||||
if (spec === '인정' || spec === '비인정') return spec;
|
||||
return '인정'; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend options → Frontend orderType 변환
|
||||
*/
|
||||
function transformOrderType(options: Record<string, unknown> | null | undefined): FrontOrderType {
|
||||
const orderType = options?.orderType as string | undefined;
|
||||
const validTypes: FrontOrderType[] = ['외주발주', '경품발주', '원자재발주'];
|
||||
if (orderType && validTypes.includes(orderType as FrontOrderType)) {
|
||||
return orderType as FrontOrderType;
|
||||
}
|
||||
return '외주발주'; // 기본값
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend is_active + options → Frontend status 변환
|
||||
*/
|
||||
function transformStatus(isActive: boolean | null | undefined, options: Record<string, unknown> | null | undefined): ItemStatus {
|
||||
const status = options?.status as string | undefined;
|
||||
if (status === '승인' || status === '작업' || status === '사용' || status === '중지') {
|
||||
return status;
|
||||
}
|
||||
return isActive ? '사용' : '중지';
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend options → Frontend orderItems 변환
|
||||
*/
|
||||
function transformOrderItems(options: Record<string, unknown> | null | undefined): OrderItem[] {
|
||||
const orderItems = options?.orderItems;
|
||||
if (Array.isArray(orderItems)) {
|
||||
return orderItems.map((item: { id?: string; label?: string; value?: string }, index: number) => ({
|
||||
id: item.id || `oi_${index}`,
|
||||
label: item.label || '',
|
||||
value: item.value || '',
|
||||
}));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → Item 타입 변환
|
||||
*/
|
||||
interface ApiItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
item_type: string | null;
|
||||
category_id: number | null;
|
||||
category?: { name?: string } | null;
|
||||
unit: string | null;
|
||||
options: Record<string, unknown> | null;
|
||||
is_active: boolean;
|
||||
description: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
function transformItem(apiItem: ApiItem): Item {
|
||||
return {
|
||||
id: String(apiItem.id),
|
||||
itemNumber: apiItem.code || '',
|
||||
itemName: apiItem.name || '',
|
||||
itemType: transformItemType(apiItem.item_type),
|
||||
categoryId: apiItem.category_id ? String(apiItem.category_id) : '',
|
||||
categoryName: apiItem.category?.name || '',
|
||||
unit: apiItem.unit || 'EA',
|
||||
specification: transformSpecification(apiItem.options),
|
||||
orderType: transformOrderType(apiItem.options),
|
||||
status: transformStatus(apiItem.is_active, apiItem.options),
|
||||
createdAt: apiItem.created_at,
|
||||
updatedAt: apiItem.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → ItemDetail 타입 변환
|
||||
*/
|
||||
function transformItemDetail(apiItem: ApiItem): ItemDetail {
|
||||
return {
|
||||
...transformItem(apiItem),
|
||||
note: apiItem.description || '',
|
||||
orderItems: transformOrderItems(apiItem.options),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformItemToApi(data: ItemFormData): Record<string, unknown> {
|
||||
return {
|
||||
code: data.itemNumber,
|
||||
name: data.itemName,
|
||||
item_type: transformToBackendItemType(data.itemType),
|
||||
category_id: data.categoryId ? parseInt(data.categoryId, 10) : null,
|
||||
unit: data.unit,
|
||||
is_active: data.status === '사용' || data.status === '승인',
|
||||
description: data.note || null,
|
||||
options: {
|
||||
specification: data.specification,
|
||||
orderType: data.orderType,
|
||||
status: data.status,
|
||||
orderItems: data.orderItems,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 품목 목록 조회
|
||||
* GET /api/v1/items
|
||||
*/
|
||||
export async function getItemList(
|
||||
params: ItemListParams = {}
|
||||
): Promise<{ success: boolean; data?: ItemListResponse; error?: string }> {
|
||||
try {
|
||||
// 시뮬레이션 딜레이
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
let filteredItems = [...mockItems];
|
||||
// 페이지네이션
|
||||
if (params.page) queryParams.page = String(params.page);
|
||||
if (params.size) queryParams.size = String(params.size);
|
||||
|
||||
// 물품유형 필터
|
||||
// 검색
|
||||
if (params.search) queryParams.q = params.search;
|
||||
|
||||
// 품목유형 필터 (Frontend → Backend 변환)
|
||||
if (params.itemType && params.itemType !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.itemType === params.itemType);
|
||||
queryParams.type = transformToBackendItemType(params.itemType as ItemType);
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (params.categoryId && params.categoryId !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.categoryId === params.categoryId);
|
||||
queryParams.category_id = params.categoryId;
|
||||
}
|
||||
|
||||
// 규격 필터
|
||||
if (params.specification && params.specification !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.specification === params.specification);
|
||||
}
|
||||
|
||||
// 구분 필터
|
||||
if (params.orderType && params.orderType !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.orderType === params.orderType);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
// 활성 상태 필터
|
||||
if (params.status && params.status !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.status === params.status);
|
||||
queryParams.active = params.status === '사용' || params.status === '승인' ? '1' : '0';
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (params.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filteredItems = filteredItems.filter(
|
||||
(item) =>
|
||||
item.itemNumber.toLowerCase().includes(search) ||
|
||||
item.itemName.toLowerCase().includes(search) ||
|
||||
item.categoryName.toLowerCase().includes(search)
|
||||
);
|
||||
const response = await apiClient.get<{
|
||||
data: ApiItem[];
|
||||
meta?: { total: number; current_page: number; per_page: number };
|
||||
total?: number;
|
||||
current_page?: number;
|
||||
per_page?: number;
|
||||
}>('/items', { params: queryParams });
|
||||
|
||||
// API 응답 구조 처리 (data 배열 또는 페이지네이션 객체)
|
||||
const items = Array.isArray(response.data) ? response.data : (response.data as unknown as ApiItem[]);
|
||||
const meta = response.meta || {
|
||||
total: response.total || items.length,
|
||||
current_page: response.current_page || params.page || 1,
|
||||
per_page: response.per_page || params.size || 20,
|
||||
};
|
||||
|
||||
// Frontend 필터링 (Backend에서 지원하지 않는 필터)
|
||||
let transformedItems = items.map(transformItem);
|
||||
|
||||
// 규격 필터 (Frontend)
|
||||
if (params.specification && params.specification !== 'all') {
|
||||
transformedItems = transformedItems.filter((item) => item.specification === params.specification);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
// 구분 필터 (Frontend)
|
||||
if (params.orderType && params.orderType !== 'all') {
|
||||
transformedItems = transformedItems.filter((item) => item.orderType === params.orderType);
|
||||
}
|
||||
|
||||
// 상태 필터 (Frontend에서 추가 처리)
|
||||
if (params.status && params.status !== 'all') {
|
||||
transformedItems = transformedItems.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
// 날짜 필터 (Frontend)
|
||||
if (params.startDate) {
|
||||
const startDate = new Date(params.startDate);
|
||||
filteredItems = filteredItems.filter((item) => new Date(item.createdAt) >= startDate);
|
||||
transformedItems = transformedItems.filter((item) => new Date(item.createdAt) >= startDate);
|
||||
}
|
||||
if (params.endDate) {
|
||||
const endDate = new Date(params.endDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
filteredItems = filteredItems.filter((item) => new Date(item.createdAt) <= endDate);
|
||||
transformedItems = transformedItems.filter((item) => new Date(item.createdAt) <= endDate);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
// 정렬 (Frontend)
|
||||
if (params.sortBy === 'oldest') {
|
||||
filteredItems.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
transformedItems.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else {
|
||||
// 기본: 최신순
|
||||
filteredItems.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
transformedItems.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params.page || 1;
|
||||
const size = params.size || 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filteredItems.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filteredItems.length,
|
||||
page,
|
||||
size,
|
||||
items: transformedItems,
|
||||
total: meta.total,
|
||||
page: meta.current_page,
|
||||
size: meta.per_page,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -190,17 +253,20 @@ export async function getItemList(
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 통계 조회
|
||||
/**
|
||||
* 품목 통계 조회
|
||||
* GET /api/v1/items/stats
|
||||
*/
|
||||
export async function getItemStats(): Promise<{ success: boolean; data?: ItemStats; error?: string }> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const total = mockItems.length;
|
||||
const active = mockItems.filter((item) => item.status === '사용' || item.status === '승인').length;
|
||||
const response = await apiClient.get<{ total: number; active: number }>('/items/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { total, active },
|
||||
data: {
|
||||
total: response.total,
|
||||
active: response.active,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('품목 통계 조회 오류:', error);
|
||||
@@ -208,17 +274,13 @@ export async function getItemStats(): Promise<{ success: boolean; data?: ItemSta
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 삭제
|
||||
/**
|
||||
* 품목 삭제
|
||||
* DELETE /api/v1/items/{id}
|
||||
*/
|
||||
export async function deleteItem(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 실제 구현에서는 API 호출
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
mockItems.splice(index, 1);
|
||||
}
|
||||
|
||||
await apiClient.delete(`/items/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('품목 삭제 오류:', error);
|
||||
@@ -226,40 +288,43 @@ export async function deleteItem(id: string): Promise<{ success: boolean; error?
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 일괄 삭제
|
||||
/**
|
||||
* 품목 일괄 삭제
|
||||
* DELETE /api/v1/items/batch
|
||||
*/
|
||||
export async function deleteItems(
|
||||
ids: string[]
|
||||
): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
let deletedCount = 0;
|
||||
ids.forEach((id) => {
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
mockItems.splice(index, 1);
|
||||
deletedCount++;
|
||||
}
|
||||
const response = await apiClient.delete<{ deleted_count: number }>('/items/batch', {
|
||||
data: { ids: ids.map((id) => parseInt(id, 10)) },
|
||||
});
|
||||
|
||||
return { success: true, deletedCount };
|
||||
return { success: true, deletedCount: response.deleted_count };
|
||||
} catch (error) {
|
||||
console.error('품목 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '품목 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 목록 조회 (필터용)
|
||||
/**
|
||||
* 카테고리 목록 조회 (필터용)
|
||||
* GET /api/v1/categories
|
||||
*/
|
||||
export async function getCategoryOptions(): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string; name: string }[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
const response = await apiClient.get<{
|
||||
data: { id: number; name: string }[];
|
||||
}>('/categories', { params: { size: 100 } });
|
||||
|
||||
// 유니크한 카테고리 추출
|
||||
const categories = [...new Map(mockItems.map((item) => [item.categoryId, { id: item.categoryId, name: item.categoryName }])).values()];
|
||||
const categories = response.data.map((cat) => ({
|
||||
id: String(cat.id),
|
||||
name: cat.name,
|
||||
}));
|
||||
|
||||
return { success: true, data: categories };
|
||||
} catch (error) {
|
||||
@@ -268,93 +333,49 @@ export async function getCategoryOptions(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// 발주 항목 목데이터
|
||||
const mockOrderItems: Record<string, OrderItem[]> = {
|
||||
'1': [
|
||||
{ id: 'oi1', label: '무게', value: '400KG' },
|
||||
{ id: 'oi2', label: '무게', value: '500KG' },
|
||||
],
|
||||
'2': [
|
||||
{ id: 'oi3', label: '전압', value: '220V' },
|
||||
],
|
||||
'3': [],
|
||||
'4': [
|
||||
{ id: 'oi4', label: '규격', value: 'M10x20' },
|
||||
],
|
||||
'5': [],
|
||||
'6': [],
|
||||
'7': [],
|
||||
};
|
||||
|
||||
// 품목 상세 조회
|
||||
/**
|
||||
* 품목 상세 조회
|
||||
* GET /api/v1/items/{id}
|
||||
*/
|
||||
export async function getItem(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ItemDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const response = await apiClient.get<ApiItem>(`/items/${id}`);
|
||||
|
||||
const item = mockItems.find((i) => i.id === id);
|
||||
if (!item) {
|
||||
return { success: false, error: '품목을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const itemDetail: ItemDetail = {
|
||||
...item,
|
||||
note: '',
|
||||
orderItems: mockOrderItems[id] || [],
|
||||
};
|
||||
|
||||
return { success: true, data: itemDetail };
|
||||
return { success: true, data: transformItemDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('품목 상세 조회 오류:', error);
|
||||
return { success: false, error: '품목 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 등록
|
||||
/**
|
||||
* 품목 등록
|
||||
* POST /api/v1/items
|
||||
*/
|
||||
export async function createItem(data: ItemFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const apiData = transformItemToApi(data);
|
||||
const response = await apiClient.post<{ id: number }>('/items', apiData);
|
||||
|
||||
// 새 ID 생성
|
||||
const newId = String(Math.max(...mockItems.map((i) => parseInt(i.id))) + 1);
|
||||
|
||||
// 카테고리명 찾기
|
||||
const category = mockItems.find((i) => i.categoryId === data.categoryId);
|
||||
const categoryName = category?.categoryName || '기본';
|
||||
|
||||
const newItem: Item = {
|
||||
id: newId,
|
||||
itemNumber: data.itemNumber,
|
||||
itemType: data.itemType,
|
||||
categoryId: data.categoryId,
|
||||
categoryName,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification,
|
||||
unit: data.unit,
|
||||
orderType: data.orderType,
|
||||
status: data.status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockItems.push(newItem);
|
||||
mockOrderItems[newId] = data.orderItems;
|
||||
|
||||
return { success: true, data: { id: newId } };
|
||||
return { success: true, data: { id: String(response.id) } };
|
||||
} catch (error) {
|
||||
console.error('품목 등록 오류:', error);
|
||||
return { success: false, error: '품목 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 수정
|
||||
/**
|
||||
* 품목 수정
|
||||
* PUT /api/v1/items/{id}
|
||||
*/
|
||||
export async function updateItem(
|
||||
id: string,
|
||||
data: ItemFormData
|
||||
@@ -363,32 +384,8 @@ export async function updateItem(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const index = mockItems.findIndex((i) => i.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '품목을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 카테고리명 찾기
|
||||
const category = mockItems.find((i) => i.categoryId === data.categoryId);
|
||||
const categoryName = category?.categoryName || mockItems[index].categoryName;
|
||||
|
||||
mockItems[index] = {
|
||||
...mockItems[index],
|
||||
itemNumber: data.itemNumber,
|
||||
itemType: data.itemType,
|
||||
categoryId: data.categoryId,
|
||||
categoryName,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification,
|
||||
unit: data.unit,
|
||||
orderType: data.orderType,
|
||||
status: data.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockOrderItems[id] = data.orderItems;
|
||||
const apiData = transformItemToApi(data);
|
||||
await apiClient.put(`/items/${id}`, apiData);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,89 +1,85 @@
|
||||
'use server';
|
||||
|
||||
import type { Labor, LaborListParams, LaborFormData, LaborStats } from './types';
|
||||
import type {
|
||||
Labor,
|
||||
LaborListParams,
|
||||
LaborFormData,
|
||||
LaborStats,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목데이터 - 7건
|
||||
const mockLabors: Labor[] = [
|
||||
{
|
||||
id: '1',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 6.00,
|
||||
laborPrice: 400000,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-03T10:00:00Z',
|
||||
updatedAt: '2026-01-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
laborNumber: '123123',
|
||||
category: '세로할증',
|
||||
minM: 3.50,
|
||||
maxM: 3.00,
|
||||
laborPrice: null,
|
||||
status: '중지',
|
||||
createdAt: '2026-01-03T09:00:00Z',
|
||||
updatedAt: '2026-01-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 6.01,
|
||||
maxM: 7.00,
|
||||
laborPrice: null,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-02T15:00:00Z',
|
||||
updatedAt: '2026-01-02T15:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
laborNumber: '123123',
|
||||
category: '세로할증',
|
||||
minM: 3.51,
|
||||
maxM: 4.50,
|
||||
laborPrice: 50000,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-02T14:00:00Z',
|
||||
updatedAt: '2026-01-02T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 6.00,
|
||||
laborPrice: null,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-01T12:00:00Z',
|
||||
updatedAt: '2026-01-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
laborNumber: '123123',
|
||||
category: '세로할증',
|
||||
minM: 3.50,
|
||||
maxM: 0,
|
||||
laborPrice: 50000,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-01T11:00:00Z',
|
||||
updatedAt: '2026-01-01T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 0,
|
||||
laborPrice: null,
|
||||
status: '중지',
|
||||
createdAt: '2026-01-01T10:00:00Z',
|
||||
updatedAt: '2026-01-01T10:00:00Z',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 시공관리 - 노임관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 노임 목록 조회
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiLabor {
|
||||
id: number;
|
||||
labor_number: string;
|
||||
category: '가로' | '세로할증';
|
||||
min_m: number;
|
||||
max_m: number;
|
||||
labor_price: number | null;
|
||||
status: '사용' | '중지';
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiLaborStats {
|
||||
total: number;
|
||||
active: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Labor 타입 변환
|
||||
*/
|
||||
function transformLabor(apiData: ApiLabor): Labor {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
laborNumber: apiData.labor_number || '',
|
||||
category: apiData.category || '가로',
|
||||
minM: apiData.min_m || 0,
|
||||
maxM: apiData.max_m || 0,
|
||||
laborPrice: apiData.labor_price,
|
||||
status: apiData.status || '사용',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* LaborFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<LaborFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.laborNumber !== undefined) apiData.labor_number = data.laborNumber;
|
||||
if (data.category !== undefined) apiData.category = data.category;
|
||||
if (data.minM !== undefined) apiData.min_m = data.minM;
|
||||
if (data.maxM !== undefined) apiData.max_m = data.maxM;
|
||||
if (data.laborPrice !== undefined) apiData.labor_price = data.laborPrice;
|
||||
if (data.status !== undefined) apiData.status = data.status;
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 노임 목록 조회
|
||||
* GET /api/v1/labor
|
||||
*/
|
||||
export async function getLaborList(params: LaborListParams = {}): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor[];
|
||||
@@ -91,125 +87,120 @@ export async function getLaborList(params: LaborListParams = {}): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let filtered = [...mockLabors];
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 검색어 필터
|
||||
if (params.search) {
|
||||
const searchLower = params.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(labor) =>
|
||||
labor.laborNumber.toLowerCase().includes(searchLower) ||
|
||||
labor.category.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
// 검색
|
||||
if (params.search) queryParams.search = params.search;
|
||||
|
||||
// 구분 필터
|
||||
if (params.category && params.category !== 'all') {
|
||||
filtered = filtered.filter((labor) => labor.category === params.category);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (params.status && params.status !== 'all') {
|
||||
filtered = filtered.filter((labor) => labor.status === params.status);
|
||||
}
|
||||
// 필터
|
||||
if (params.category && params.category !== 'all') queryParams.category = params.category;
|
||||
if (params.status && params.status !== 'all') queryParams.status = params.status;
|
||||
|
||||
// 날짜 필터
|
||||
if (params.startDate) {
|
||||
filtered = filtered.filter(
|
||||
(labor) => new Date(labor.createdAt) >= new Date(params.startDate!)
|
||||
);
|
||||
}
|
||||
if (params.endDate) {
|
||||
const endDate = new Date(params.endDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
filtered = filtered.filter(
|
||||
(labor) => new Date(labor.createdAt) <= endDate
|
||||
);
|
||||
}
|
||||
if (params.startDate) queryParams.start_date = params.startDate;
|
||||
if (params.endDate) queryParams.end_date = params.endDate;
|
||||
|
||||
// 페이지네이션
|
||||
if (params.page) queryParams.page = String(params.page);
|
||||
if (params.limit) queryParams.per_page = String(params.limit);
|
||||
|
||||
// 정렬
|
||||
if (params.sortOrder === '등록순') {
|
||||
filtered.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
queryParams.sort_by = 'created_at';
|
||||
queryParams.sort_dir = 'asc';
|
||||
} else {
|
||||
// 기본: 최신순
|
||||
filtered.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
queryParams.sort_by = 'created_at';
|
||||
queryParams.sort_dir = 'desc';
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
const response = await apiClient.get<{
|
||||
data: ApiLabor[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/labor', { params: queryParams });
|
||||
|
||||
// 페이지네이션
|
||||
if (params.page && params.limit) {
|
||||
const start = (params.page - 1) * params.limit;
|
||||
filtered = filtered.slice(start, start + params.limit);
|
||||
}
|
||||
const items = (response.data || []).map(transformLabor);
|
||||
|
||||
return { success: true, data: filtered, total };
|
||||
return {
|
||||
success: true,
|
||||
data: items,
|
||||
total: response.total || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('노임 목록 조회 실패:', error);
|
||||
return { success: false, error: '노임 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 통계 조회
|
||||
/**
|
||||
* 노임 통계 조회
|
||||
* GET /api/v1/labor/stats
|
||||
*/
|
||||
export async function getLaborStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: LaborStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const total = mockLabors.length;
|
||||
const active = mockLabors.filter((labor) => labor.status === '사용').length;
|
||||
return { success: true, data: { total, active } };
|
||||
const response = await apiClient.get<ApiLaborStats>('/labor/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: response.total || 0,
|
||||
active: response.active || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('노임 통계 조회 실패:', error);
|
||||
return { success: false, error: '노임 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 상세 조회
|
||||
/**
|
||||
* 노임 상세 조회
|
||||
* GET /api/v1/labor/{id}
|
||||
*/
|
||||
export async function getLabor(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const labor = mockLabors.find((l) => l.id === id);
|
||||
if (!labor) {
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: labor };
|
||||
const response = await apiClient.get<ApiLabor>(`/labor/${id}`);
|
||||
return { success: true, data: transformLabor(response) };
|
||||
} catch (error) {
|
||||
console.error('노임 상세 조회 실패:', error);
|
||||
return { success: false, error: '노임 정보를 불러오는데 실패했습니다.' };
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 등록
|
||||
/**
|
||||
* 노임 등록
|
||||
* POST /api/v1/labor
|
||||
*/
|
||||
export async function createLabor(data: LaborFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const newLabor: Labor = {
|
||||
id: String(Date.now()),
|
||||
...data,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
mockLabors.unshift(newLabor);
|
||||
return { success: true, data: newLabor };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.post<ApiLabor>('/labor', apiData);
|
||||
return { success: true, data: transformLabor(response) };
|
||||
} catch (error) {
|
||||
console.error('노임 등록 실패:', error);
|
||||
return { success: false, error: '노임 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 수정
|
||||
/**
|
||||
* 노임 수정
|
||||
* PUT /api/v1/labor/{id}
|
||||
*/
|
||||
export async function updateLabor(
|
||||
id: string,
|
||||
data: LaborFormData
|
||||
@@ -219,33 +210,25 @@ export async function updateLabor(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockLabors.findIndex((l) => l.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockLabors[index] = {
|
||||
...mockLabors[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return { success: true, data: mockLabors[index] };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiLabor>(`/labor/${id}`, apiData);
|
||||
return { success: true, data: transformLabor(response) };
|
||||
} catch (error) {
|
||||
console.error('노임 수정 실패:', error);
|
||||
return { success: false, error: '노임 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 삭제
|
||||
/**
|
||||
* 노임 삭제
|
||||
* DELETE /api/v1/labor/{id}
|
||||
*/
|
||||
export async function deleteLabor(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockLabors.findIndex((l) => l.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockLabors.splice(index, 1);
|
||||
await apiClient.delete(`/labor/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('노임 삭제 실패:', error);
|
||||
@@ -253,22 +236,20 @@ export async function deleteLabor(id: string): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 일괄 삭제
|
||||
/**
|
||||
* 노임 일괄 삭제
|
||||
* DELETE /api/v1/labor/bulk
|
||||
*/
|
||||
export async function deleteLaborBulk(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let deletedCount = 0;
|
||||
for (const id of ids) {
|
||||
const index = mockLabors.findIndex((l) => l.id === id);
|
||||
if (index !== -1) {
|
||||
mockLabors.splice(index, 1);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
return { success: true, deletedCount };
|
||||
await apiClient.delete('/labor/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('노임 일괄 삭제 실패:', error);
|
||||
return { success: false, error: '노임 일괄 삭제에 실패했습니다.' };
|
||||
|
||||
@@ -1,131 +1,189 @@
|
||||
'use server';
|
||||
|
||||
import type { Order, OrderStats, OrderType, OrderDetail, OrderDetailFormData } from './types';
|
||||
import { MOCK_ORDER_DETAIL } from './types';
|
||||
import { format, addDays, subDays, subMonths } from 'date-fns';
|
||||
import type { Order, OrderStats, OrderDetail, OrderDetailFormData, OrderStatus, OrderType } from './types';
|
||||
import { apiClient, getOrderStatusOptions, getOrderTypeOptions } from '@/lib/api';
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 목업 발주 데이터 생성 (고정 데이터)
|
||||
* - types.ts의 MOCK 옵션들과 정확히 일치해야 필터가 동작함
|
||||
* - Math.random() 제거 → index 기반 deterministic 데이터
|
||||
* Backend status_code → Frontend OrderStatus 변환
|
||||
* DRAFT → waiting, CONFIRMED → order_complete, IN_PROGRESS → delivery_scheduled, COMPLETED → delivery_complete
|
||||
*/
|
||||
function generateMockOrders(): Order[] {
|
||||
// types.ts MOCK_PARTNERS와 일치
|
||||
const partners = [
|
||||
{ id: '1', name: '(주)대한건설' },
|
||||
{ id: '2', name: '삼성물산' },
|
||||
{ id: '3', name: '현대건설' },
|
||||
{ id: '4', name: 'GS건설' },
|
||||
{ id: '5', name: '대림산업' },
|
||||
];
|
||||
|
||||
// types.ts MOCK_SITES와 일치
|
||||
const sites = [
|
||||
'강남 오피스빌딩 신축',
|
||||
'판교 데이터센터',
|
||||
'송도 물류센터',
|
||||
'인천공항 터미널',
|
||||
'부산항 창고',
|
||||
];
|
||||
|
||||
const names = [
|
||||
'철근 HD13',
|
||||
'철근 HD16',
|
||||
'철근 HD19',
|
||||
'철근 HD22',
|
||||
'H빔 300x300',
|
||||
'H빔 200x200',
|
||||
'콘크리트 25-21-12',
|
||||
'레미콘 배합',
|
||||
];
|
||||
|
||||
const items = [
|
||||
'철근 HD13',
|
||||
'철근 HD16',
|
||||
'철근 HD19',
|
||||
'H빔',
|
||||
'레미콘',
|
||||
'앵커볼트',
|
||||
'데크플레이트',
|
||||
'용접봉',
|
||||
];
|
||||
|
||||
// types.ts MOCK_CONSTRUCTION_PM과 일치
|
||||
const constructionPMs = ['홍길동', '김철수', '이영희', '박민수'];
|
||||
// types.ts MOCK_ORDER_MANAGERS와 일치
|
||||
const orderManagers = ['김담당', '이담당', '박담당', '최담당'];
|
||||
// types.ts MOCK_ORDER_COMPANIES와 일치
|
||||
const orderCompanies = ['A건설', 'B철강', 'C자재', 'D산업'];
|
||||
// types.ts MOCK_WORK_TEAM_LEADERS와 일치
|
||||
const workTeamLeaders = ['이반장', '김반장', '박반장', '최반장'];
|
||||
const orderTypes: OrderType[] = ['steel_bar', 'material', 'outsourcing'];
|
||||
const statuses: Order['status'][] = ['waiting', 'order_complete', 'delivery_scheduled', 'delivery_complete'];
|
||||
|
||||
const orders: Order[] = [];
|
||||
// 고정 기준일 (2026-01-06)
|
||||
const baseDate = new Date(2026, 0, 6);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
// index 기반 deterministic 선택 (랜덤 제거)
|
||||
const partner = partners[i % partners.length];
|
||||
const site = sites[i % sites.length];
|
||||
const status = statuses[i % statuses.length];
|
||||
const orderType = orderTypes[i % orderTypes.length];
|
||||
|
||||
// 날짜도 index 기반으로 고정
|
||||
const monthOffset = i % 3; // 0, 1, 2개월 전
|
||||
const dayOffset = (i * 3) % 30; // 0~29일 분산
|
||||
const periodStart = subMonths(addDays(baseDate, -dayOffset), monthOffset);
|
||||
const periodEnd = addDays(periodStart, 10 + (i % 20)); // 10~29일 기간
|
||||
const orderDate = subDays(periodStart, i % 5);
|
||||
const constructionStartDate = addDays(periodStart, i % 5);
|
||||
const plannedDelivery = addDays(orderDate, 3 + (i % 14));
|
||||
const actualDelivery = status === 'delivery_complete'
|
||||
? format(addDays(plannedDelivery, (i % 5) - 2), 'yyyy-MM-dd')
|
||||
: null;
|
||||
|
||||
orders.push({
|
||||
id: `order-${i + 1}`,
|
||||
contractNumber: `CT-${2026}-${String(i + 1).padStart(4, '0')}`,
|
||||
partnerId: partner.id,
|
||||
partnerName: partner.name,
|
||||
siteName: site,
|
||||
name: names[i % names.length],
|
||||
constructionPM: constructionPMs[i % constructionPMs.length],
|
||||
orderManager: orderManagers[i % orderManagers.length],
|
||||
orderNumber: `ORD-${2026}-${String(i + 1).padStart(4, '0')}`,
|
||||
orderCompany: orderCompanies[i % orderCompanies.length],
|
||||
workTeamLeader: workTeamLeaders[i % workTeamLeaders.length],
|
||||
constructionStartDate: format(constructionStartDate, 'yyyy-MM-dd'),
|
||||
orderType,
|
||||
item: items[i % items.length],
|
||||
quantity: 10 + (i * 7) % 90, // 10~99 고정 패턴
|
||||
orderDate: format(orderDate, 'yyyy-MM-dd'),
|
||||
plannedDeliveryDate: format(plannedDelivery, 'yyyy-MM-dd'),
|
||||
actualDeliveryDate: actualDelivery,
|
||||
status,
|
||||
periodStart: format(periodStart, 'yyyy-MM-dd'),
|
||||
periodEnd: format(periodEnd, 'yyyy-MM-dd'),
|
||||
createdAt: format(subDays(periodStart, i % 10), 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||
updatedAt: format(baseDate, 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||
});
|
||||
}
|
||||
|
||||
return orders;
|
||||
function transformStatus(backendStatus: string | null | undefined): OrderStatus {
|
||||
const statusMap: Record<string, OrderStatus> = {
|
||||
DRAFT: 'waiting',
|
||||
CONFIRMED: 'order_complete',
|
||||
IN_PROGRESS: 'delivery_scheduled',
|
||||
COMPLETED: 'delivery_complete',
|
||||
CANCELLED: 'waiting', // 취소는 대기로 표시
|
||||
};
|
||||
return statusMap[backendStatus?.toUpperCase() || ''] || 'waiting';
|
||||
}
|
||||
|
||||
// 캐시된 목업 데이터
|
||||
let cachedOrders: Order[] | null = null;
|
||||
|
||||
function getMockOrders(): Order[] {
|
||||
if (!cachedOrders) {
|
||||
cachedOrders = generateMockOrders();
|
||||
}
|
||||
return cachedOrders;
|
||||
/**
|
||||
* Frontend OrderStatus → Backend status_code 변환
|
||||
*/
|
||||
function transformToBackendStatus(frontendStatus: OrderStatus): string {
|
||||
const statusMap: Record<OrderStatus, string> = {
|
||||
waiting: 'DRAFT',
|
||||
order_complete: 'CONFIRMED',
|
||||
delivery_scheduled: 'IN_PROGRESS',
|
||||
delivery_complete: 'COMPLETED',
|
||||
};
|
||||
return statusMap[frontendStatus] || 'DRAFT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Backend order_type_code → Frontend OrderType 변환
|
||||
*/
|
||||
function transformOrderType(backendType: string | null | undefined): OrderType {
|
||||
// Backend: ORDER, PURCHASE
|
||||
// Frontend: steel_bar, material, outsourcing
|
||||
// 현재 Backend는 ORDER/PURCHASE만 있으므로 options에서 가져오거나 기본값 사용
|
||||
const typeMap: Record<string, OrderType> = {
|
||||
ORDER: 'steel_bar',
|
||||
PURCHASE: 'material',
|
||||
};
|
||||
return typeMap[backendType?.toUpperCase() || ''] || 'steel_bar';
|
||||
}
|
||||
|
||||
/**
|
||||
* Frontend OrderType → Backend order_type_code 변환
|
||||
*/
|
||||
function transformToBackendOrderType(frontendType: OrderType): string {
|
||||
const typeMap: Record<OrderType, string> = {
|
||||
steel_bar: 'ORDER',
|
||||
material: 'PURCHASE',
|
||||
outsourcing: 'ORDER',
|
||||
};
|
||||
return typeMap[frontendType] || 'ORDER';
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiOrder {
|
||||
id: number;
|
||||
order_no: string;
|
||||
client_id: number | null;
|
||||
client_name: string | null;
|
||||
client?: { id: number; name: string } | null;
|
||||
site_name: string | null;
|
||||
status_code: string;
|
||||
order_type_code: string;
|
||||
received_at: string | null;
|
||||
delivery_date: string | null;
|
||||
actual_delivery_date: string | null;
|
||||
total_amount: number | null;
|
||||
supply_amount: number | null;
|
||||
tax_amount: number | null;
|
||||
memo: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
items?: ApiOrderItem[];
|
||||
quote?: { id: number; quote_no: string; site_name: string } | null;
|
||||
}
|
||||
|
||||
interface ApiOrderItem {
|
||||
id: number;
|
||||
item_id: number | null;
|
||||
item_name: string;
|
||||
specification: string | null;
|
||||
quantity: number;
|
||||
unit: string | null;
|
||||
unit_price: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
interface ApiOrderStats {
|
||||
total: number;
|
||||
draft: number;
|
||||
confirmed: number;
|
||||
in_progress: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
total_amount: number;
|
||||
confirmed_amount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → Order 타입 변환
|
||||
*/
|
||||
function transformOrder(apiOrder: ApiOrder): Order {
|
||||
return {
|
||||
id: String(apiOrder.id),
|
||||
contractNumber: apiOrder.quote?.quote_no || '',
|
||||
partnerId: apiOrder.client_id ? String(apiOrder.client_id) : '',
|
||||
partnerName: apiOrder.client?.name || apiOrder.client_name || '',
|
||||
siteName: apiOrder.site_name || '',
|
||||
name: apiOrder.items?.[0]?.item_name || '',
|
||||
constructionPM: '', // Backend에 없음 - options로 확장 가능
|
||||
orderManager: '', // Backend에 없음 - options로 확장 가능
|
||||
orderNumber: apiOrder.order_no,
|
||||
orderCompany: '', // Backend에 없음 - options로 확장 가능
|
||||
workTeamLeader: '', // Backend에 없음 - options로 확장 가능
|
||||
constructionStartDate: apiOrder.received_at || '',
|
||||
orderType: transformOrderType(apiOrder.order_type_code),
|
||||
item: apiOrder.items?.[0]?.item_name || '',
|
||||
quantity: apiOrder.items?.[0]?.quantity || 0,
|
||||
orderDate: apiOrder.received_at || '',
|
||||
plannedDeliveryDate: apiOrder.delivery_date || '',
|
||||
actualDeliveryDate: apiOrder.actual_delivery_date || null,
|
||||
status: transformStatus(apiOrder.status_code),
|
||||
periodStart: apiOrder.received_at || '',
|
||||
periodEnd: apiOrder.delivery_date || '',
|
||||
createdAt: apiOrder.created_at,
|
||||
updatedAt: apiOrder.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → OrderDetail 타입 변환
|
||||
*/
|
||||
function transformOrderDetail(apiOrder: ApiOrder): OrderDetail {
|
||||
const baseOrder = transformOrder(apiOrder);
|
||||
|
||||
return {
|
||||
...baseOrder,
|
||||
orderCompanyId: '', // Backend에 없음
|
||||
deliveryLocationType: 'site',
|
||||
deliveryAddress: '', // Backend에 없음
|
||||
deliveryMemo: apiOrder.memo || '',
|
||||
totalAmount: apiOrder.total_amount || 0,
|
||||
supplyAmount: apiOrder.supply_amount || 0,
|
||||
taxAmount: apiOrder.tax_amount || 0,
|
||||
categories: [], // Backend 구조와 다름 - items를 카테고리로 그룹화 필요
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OrderDetailFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformOrderToApi(data: OrderDetailFormData): Record<string, unknown> {
|
||||
return {
|
||||
client_id: data.partnerId ? parseInt(data.partnerId, 10) : null,
|
||||
site_name: data.siteName,
|
||||
status_code: transformToBackendStatus(data.status),
|
||||
order_type_code: transformToBackendOrderType(data.orderType),
|
||||
delivery_date: data.deliveryAddress ? undefined : undefined, // 필드 매핑 필요
|
||||
memo: data.deliveryMemo,
|
||||
// items 변환은 별도 처리 필요
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 발주 목록 조회
|
||||
* GET /api/v1/orders
|
||||
*/
|
||||
export async function getOrderList(params?: {
|
||||
size?: number;
|
||||
@@ -141,61 +199,67 @@ export async function getOrderList(params?: {
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업 데이터
|
||||
let orders = getMockOrders();
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 날짜 필터
|
||||
if (params?.startDate && params?.endDate) {
|
||||
orders = orders.filter((order) => {
|
||||
return order.periodStart >= params.startDate! && order.periodEnd <= params.endDate!;
|
||||
});
|
||||
}
|
||||
// 페이지네이션
|
||||
if (params?.page) queryParams.page = String(params.page);
|
||||
if (params?.size) queryParams.size = String(params.size);
|
||||
|
||||
// 상태 필터
|
||||
// 검색
|
||||
if (params?.search) queryParams.q = params.search;
|
||||
|
||||
// 상태 필터 (Frontend → Backend 변환)
|
||||
if (params?.status && params.status !== 'all') {
|
||||
orders = orders.filter((order) => order.status === params.status);
|
||||
queryParams.status = transformToBackendStatus(params.status as OrderStatus);
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (params?.partnerId && params.partnerId !== 'all') {
|
||||
orders = orders.filter((order) => order.partnerId === params.partnerId);
|
||||
queryParams.client_id = params.partnerId;
|
||||
}
|
||||
|
||||
// 검색
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
orders = orders.filter(
|
||||
(order) =>
|
||||
order.orderNumber.toLowerCase().includes(search) ||
|
||||
order.partnerName.toLowerCase().includes(search) ||
|
||||
order.siteName.toLowerCase().includes(search) ||
|
||||
order.orderManager.toLowerCase().includes(search)
|
||||
);
|
||||
// 날짜 범위 필터
|
||||
if (params?.startDate) {
|
||||
queryParams.date_from = params.startDate;
|
||||
}
|
||||
if (params?.endDate) {
|
||||
queryParams.date_to = params.endDate;
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params?.page || 1;
|
||||
const size = params?.size || 1000;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedOrders = orders.slice(start, start + size);
|
||||
const response = await apiClient.get<{
|
||||
data: ApiOrder[];
|
||||
meta?: { total: number; current_page: number; per_page: number };
|
||||
total?: number;
|
||||
current_page?: number;
|
||||
per_page?: number;
|
||||
}>('/orders', { params: queryParams });
|
||||
|
||||
// API 응답 구조 처리
|
||||
const orders = Array.isArray(response.data) ? response.data : (response.data as unknown as ApiOrder[]);
|
||||
const meta = response.meta || {
|
||||
total: response.total || orders.length,
|
||||
current_page: response.current_page || params?.page || 1,
|
||||
per_page: response.per_page || params?.size || 20,
|
||||
};
|
||||
|
||||
const transformedOrders = orders.map(transformOrder);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedOrders,
|
||||
total: orders.length,
|
||||
items: transformedOrders,
|
||||
total: meta.total,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 목록 조회에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 목록 조회 오류:', error);
|
||||
return { success: false, error: '발주 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 통계 조회
|
||||
* GET /api/v1/orders/stats
|
||||
*/
|
||||
export async function getOrderStats(): Promise<{
|
||||
success: boolean;
|
||||
@@ -203,52 +267,44 @@ export async function getOrderStats(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const orders = getMockOrders();
|
||||
|
||||
const stats: OrderStats = {
|
||||
total: orders.length,
|
||||
waiting: orders.filter((o) => o.status === 'waiting').length,
|
||||
orderComplete: orders.filter((o) => o.status === 'order_complete').length,
|
||||
deliveryScheduled: orders.filter((o) => o.status === 'delivery_scheduled').length,
|
||||
deliveryComplete: orders.filter((o) => o.status === 'delivery_complete').length,
|
||||
};
|
||||
const response = await apiClient.get<ApiOrderStats>('/orders/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: stats,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 통계 조회에 실패했습니다.',
|
||||
data: {
|
||||
total: response.total,
|
||||
waiting: response.draft,
|
||||
orderComplete: response.confirmed,
|
||||
deliveryScheduled: response.in_progress,
|
||||
deliveryComplete: response.completed,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 통계 조회 오류:', error);
|
||||
return { success: false, error: '발주 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 삭제
|
||||
* DELETE /api/v1/orders/{id}
|
||||
*/
|
||||
export async function deleteOrder(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
if (cachedOrders) {
|
||||
cachedOrders = cachedOrders.filter((o) => o.id !== id);
|
||||
}
|
||||
|
||||
await apiClient.delete(`/orders/${id}`);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 삭제에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 삭제 오류:', error);
|
||||
return { success: false, error: '발주 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 일괄 삭제
|
||||
* Backend에 batch API가 없으므로 개별 삭제 반복
|
||||
*/
|
||||
export async function deleteOrders(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
@@ -256,32 +312,28 @@ export async function deleteOrders(ids: string[]): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
if (cachedOrders) {
|
||||
const beforeCount = cachedOrders.length;
|
||||
cachedOrders = cachedOrders.filter((o) => !ids.includes(o.id));
|
||||
const deletedCount = beforeCount - cachedOrders.length;
|
||||
let deletedCount = 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount,
|
||||
};
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await apiClient.delete(`/orders/${id}`);
|
||||
deletedCount++;
|
||||
} catch {
|
||||
// 개별 삭제 실패는 무시하고 계속 진행
|
||||
console.warn(`발주 삭제 실패: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: ids.length,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 일괄 삭제에 실패했습니다.',
|
||||
};
|
||||
return { success: true, deletedCount };
|
||||
} catch (error) {
|
||||
console.error('발주 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '발주 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 상세 조회
|
||||
* GET /api/v1/orders/{id}
|
||||
*/
|
||||
export async function getOrderDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
@@ -289,30 +341,17 @@ export async function getOrderDetail(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const orders = getMockOrders();
|
||||
const order = orders.find((o) => o.id === id);
|
||||
|
||||
if (!order) {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: order,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 상세 조회에 실패했습니다.',
|
||||
};
|
||||
const response = await apiClient.get<ApiOrder>(`/orders/${id}`);
|
||||
return { success: true, data: transformOrder(response) };
|
||||
} catch (error) {
|
||||
console.error('발주 상세 조회 오류:', error);
|
||||
return { success: false, error: '발주를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 상세 조회 (전체 정보)
|
||||
* GET /api/v1/orders/{id}
|
||||
*/
|
||||
export async function getOrderDetailFull(id: string): Promise<{
|
||||
success: boolean;
|
||||
@@ -320,27 +359,17 @@ export async function getOrderDetailFull(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
// 임시로 MOCK_ORDER_DETAIL 반환
|
||||
const mockDetail: OrderDetail = {
|
||||
...MOCK_ORDER_DETAIL,
|
||||
id,
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: mockDetail,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 상세 조회에 실패했습니다.',
|
||||
};
|
||||
const response = await apiClient.get<ApiOrder>(`/orders/${id}`);
|
||||
return { success: true, data: transformOrderDetail(response) };
|
||||
} catch (error) {
|
||||
console.error('발주 상세 조회 오류:', error);
|
||||
return { success: false, error: '발주 상세 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 수정
|
||||
* PUT /api/v1/orders/{id}
|
||||
*/
|
||||
export async function updateOrder(
|
||||
id: string,
|
||||
@@ -350,20 +379,18 @@ export async function updateOrder(
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
console.log('Updating order:', id, data);
|
||||
|
||||
const apiData = transformOrderToApi(data);
|
||||
await apiClient.put(`/orders/${id}`, apiData);
|
||||
return { success: true };
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 수정에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 수정 오류:', error);
|
||||
return { success: false, error: '발주 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 복제
|
||||
* 기존 발주를 조회 후 새로 생성
|
||||
*/
|
||||
export async function duplicateOrder(id: string): Promise<{
|
||||
success: boolean;
|
||||
@@ -371,18 +398,83 @@ export async function duplicateOrder(id: string): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목업: 실제로는 API 호출
|
||||
const newId = `order-${Date.now()}`;
|
||||
console.log('Duplicating order:', id, '-> new id:', newId);
|
||||
// 1. 기존 발주 조회
|
||||
const existingOrder = await apiClient.get<ApiOrder>(`/orders/${id}`);
|
||||
|
||||
// 2. 새 발주 생성 (order_no는 자동 생성됨)
|
||||
const newOrderData = {
|
||||
client_id: existingOrder.client_id,
|
||||
site_name: existingOrder.site_name,
|
||||
status_code: 'DRAFT', // 복제된 발주는 항상 임시저장
|
||||
order_type_code: existingOrder.order_type_code,
|
||||
delivery_date: existingOrder.delivery_date,
|
||||
memo: existingOrder.memo ? `[복제] ${existingOrder.memo}` : '[복제됨]',
|
||||
items: existingOrder.items?.map((item) => ({
|
||||
item_id: item.item_id,
|
||||
item_name: item.item_name,
|
||||
specification: item.specification,
|
||||
quantity: item.quantity,
|
||||
unit: item.unit,
|
||||
unit_price: item.unit_price,
|
||||
})),
|
||||
};
|
||||
|
||||
const response = await apiClient.post<{ id: number }>('/orders', newOrderData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newId,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: '발주 복제에 실패했습니다.',
|
||||
newId: String(response.id),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('발주 복제 오류:', error);
|
||||
return { success: false, error: '발주 복제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 생성
|
||||
* POST /api/v1/orders
|
||||
*/
|
||||
export async function createOrder(data: OrderDetailFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformOrderToApi(data);
|
||||
const response = await apiClient.post<{ id: number }>('/orders', apiData);
|
||||
|
||||
return { success: true, data: { id: String(response.id) } };
|
||||
} catch (error) {
|
||||
console.error('발주 생성 오류:', error);
|
||||
return { success: false, error: '발주 생성에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 상태 변경
|
||||
* PATCH /api/v1/orders/{id}/status
|
||||
*/
|
||||
export async function updateOrderStatus(
|
||||
id: string,
|
||||
status: OrderStatus
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.patch(`/orders/${id}/status`, {
|
||||
status: transformToBackendStatus(status),
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('발주 상태 변경 오류:', error);
|
||||
return { success: false, error: '발주 상태 변경에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 공통 코드 조회 (재사용)
|
||||
// ========================================
|
||||
|
||||
export { getOrderStatusOptions, getOrderTypeOptions };
|
||||
@@ -33,13 +33,13 @@ import { toast } from 'sonner';
|
||||
import type { Partner, PartnerFormData, PartnerMemo, PartnerDocument } from './types';
|
||||
import {
|
||||
PARTNER_TYPE_OPTIONS,
|
||||
CATEGORY_OPTIONS,
|
||||
CREDIT_RATING_OPTIONS,
|
||||
TRANSACTION_GRADE_OPTIONS,
|
||||
PAYMENT_DAY_OPTIONS,
|
||||
getEmptyPartnerFormData,
|
||||
partnerToFormData,
|
||||
} from './types';
|
||||
import { createPartner, updatePartner, deletePartner } from './actions';
|
||||
|
||||
// 목업 문서 목록 (상세 모드에서 다운로드 버튼 테스트용)
|
||||
const MOCK_DOCUMENTS: PartnerDocument[] = [
|
||||
@@ -158,8 +158,19 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
let result;
|
||||
if (isNewMode) {
|
||||
result = await createPartner(formData);
|
||||
} else if (partnerId) {
|
||||
result = await updatePartner(partnerId, formData);
|
||||
} else {
|
||||
throw new Error('거래처 ID가 없습니다.');
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success(isNewMode ? '거래처가 등록되었습니다.' : '수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push('/ko/construction/project/bidding/partners');
|
||||
@@ -169,7 +180,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isNewMode, router]);
|
||||
}, [isNewMode, partnerId, formData, router]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
@@ -177,10 +188,19 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!partnerId) {
|
||||
toast.error('거래처 ID가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const result = await deletePartner(partnerId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success('거래처가 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/construction/project/bidding/partners');
|
||||
@@ -190,7 +210,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
}, [partnerId, router]);
|
||||
|
||||
// 메모 추가 핸들러
|
||||
const handleAddMemo = useCallback(() => {
|
||||
@@ -493,8 +513,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
{renderField('대표자명', 'representative', formData.representative)}
|
||||
{renderSelectField('거래처 유형', 'partnerType', formData.partnerType, PARTNER_TYPE_OPTIONS)}
|
||||
{renderField('업태', 'businessType', formData.businessType)}
|
||||
{renderSelectField('업종', 'category', formData.category, CATEGORY_OPTIONS)}
|
||||
{renderField('업종(직접입력)', 'businessCategory', formData.businessCategory)}
|
||||
{renderField('업종', 'businessCategory', formData.businessCategory)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,373 +1,357 @@
|
||||
'use server';
|
||||
|
||||
import type { Partner, PartnerStats, PartnerFilter, PartnerListResponse, PartnerFormData } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* 주일 기업 - 거래처 관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 목업 데이터 (확장된 타입 적용)
|
||||
const mockPartners: Partner[] = [
|
||||
{
|
||||
id: '1',
|
||||
partnerCode: 'P-001',
|
||||
businessNumber: '123-12-12345',
|
||||
partnerName: '대한건설',
|
||||
representative: '홍길동',
|
||||
partnerType: 'sales',
|
||||
businessType: '건설업',
|
||||
businessCategory: '토목건축',
|
||||
zipCode: '06234',
|
||||
address1: '서울특별시 서초구 서초대로 123',
|
||||
address2: '대한건물 12층 1201호',
|
||||
phone: '02-1234-1234',
|
||||
mobile: '010-1234-1234',
|
||||
fax: '02-1234-1235',
|
||||
email: 'abc@email.com',
|
||||
manager: '담당자명',
|
||||
managerPhone: '010-1234-1234',
|
||||
systemManager: '관리자명',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 15,
|
||||
creditRating: 'AAA',
|
||||
transactionGrade: 'A',
|
||||
taxInvoiceEmail: 'abc@email.com',
|
||||
outstandingAmount: 11000000,
|
||||
overdueDays: 15,
|
||||
overdueToggle: true,
|
||||
badDebtToggle: false,
|
||||
memos: [
|
||||
{
|
||||
id: '1',
|
||||
content: '2025-12-12 12:21 [홍길동] 메모 내용',
|
||||
createdAt: '2025-12-12T12:21:00Z',
|
||||
},
|
||||
],
|
||||
documents: [],
|
||||
category: '건설사',
|
||||
paymentDay: 15,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
partnerCode: 'P-002',
|
||||
businessNumber: '456-45-45678',
|
||||
partnerName: '삼성시공',
|
||||
representative: '김철수',
|
||||
partnerType: 'purchase',
|
||||
businessType: '시공업',
|
||||
businessCategory: '건축시공',
|
||||
zipCode: '06235',
|
||||
address1: '서울특별시 강남구 테헤란로 456',
|
||||
address2: '삼성빌딩 5층',
|
||||
phone: '02-5678-5678',
|
||||
mobile: '010-5678-5678',
|
||||
fax: '02-5678-5679',
|
||||
email: 'samsung@email.com',
|
||||
manager: '이영희',
|
||||
managerPhone: '010-5678-5678',
|
||||
systemManager: '',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 10,
|
||||
creditRating: 'AA',
|
||||
transactionGrade: 'B',
|
||||
taxInvoiceEmail: 'tax@samsung.com',
|
||||
outstandingAmount: 5000000,
|
||||
overdueDays: 0,
|
||||
overdueToggle: false,
|
||||
badDebtToggle: false,
|
||||
memos: [],
|
||||
documents: [],
|
||||
category: '시공사',
|
||||
paymentDay: 10,
|
||||
isBadDebt: false,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
partnerCode: 'P-003',
|
||||
businessNumber: '789-78-78901',
|
||||
partnerName: 'LG건설',
|
||||
representative: '박영수',
|
||||
partnerType: 'both',
|
||||
businessType: '종합건설',
|
||||
businessCategory: '건설',
|
||||
zipCode: '06236',
|
||||
address1: '서울특별시 영등포구 여의대로 789',
|
||||
address2: 'LG타워 20층',
|
||||
phone: '02-7890-7890',
|
||||
mobile: '010-7890-7890',
|
||||
fax: '02-7890-7891',
|
||||
email: 'lg@email.com',
|
||||
manager: '최민수',
|
||||
managerPhone: '010-7890-7890',
|
||||
systemManager: '시스템관리자',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 20,
|
||||
creditRating: 'BBB',
|
||||
transactionGrade: 'C',
|
||||
taxInvoiceEmail: 'tax@lg.com',
|
||||
outstandingAmount: 20000000,
|
||||
overdueDays: 30,
|
||||
overdueToggle: true,
|
||||
badDebtToggle: true,
|
||||
memos: [],
|
||||
documents: [],
|
||||
category: '건설사',
|
||||
paymentDay: 20,
|
||||
isBadDebt: true,
|
||||
isActive: true,
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
},
|
||||
];
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
// 거래처 목록 조회
|
||||
export async function getPartnerList(
|
||||
filter?: PartnerFilter
|
||||
): Promise<{ success: boolean; data?: PartnerListResponse; error?: string }> {
|
||||
interface ApiPartner {
|
||||
id: number;
|
||||
client_code: string | null;
|
||||
business_no: string | null;
|
||||
name: string;
|
||||
contact_person: string | null;
|
||||
client_type: string | null;
|
||||
business_type: string | null;
|
||||
business_item: string | null;
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
mobile: string | null;
|
||||
fax: string | null;
|
||||
email: string | null;
|
||||
manager_name: string | null;
|
||||
manager_tel: string | null;
|
||||
system_manager: string | null;
|
||||
outstanding_amount: number | null;
|
||||
is_overdue: boolean;
|
||||
has_bad_debt: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiPartnerStats {
|
||||
total: number;
|
||||
sales: number;
|
||||
purchase: number;
|
||||
both: number;
|
||||
badDebt: number;
|
||||
normal: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* client_type API → Frontend partnerType 변환
|
||||
*/
|
||||
function transformClientType(clientType: string | null | undefined): Partner['partnerType'] {
|
||||
const typeMap: Record<string, Partner['partnerType']> = {
|
||||
SALES: 'sales',
|
||||
PURCHASE: 'purchase',
|
||||
BOTH: 'both',
|
||||
};
|
||||
return typeMap[clientType || ''] || 'sales';
|
||||
}
|
||||
|
||||
/**
|
||||
* partnerType Frontend → API client_type 변환
|
||||
*/
|
||||
function transformPartnerType(partnerType: Partner['partnerType']): string {
|
||||
const typeMap: Record<Partner['partnerType'], string> = {
|
||||
sales: 'SALES',
|
||||
purchase: 'PURCHASE',
|
||||
both: 'BOTH',
|
||||
};
|
||||
return typeMap[partnerType] || 'SALES';
|
||||
}
|
||||
|
||||
/**
|
||||
* client_type → 구분 라벨 변환
|
||||
*/
|
||||
function getPartnerTypeLabel(clientType: string | null | undefined): string {
|
||||
const labelMap: Record<string, string> = {
|
||||
SALES: '매출',
|
||||
PURCHASE: '매입',
|
||||
BOTH: '복합',
|
||||
};
|
||||
return labelMap[clientType || ''] || '매출';
|
||||
}
|
||||
|
||||
/**
|
||||
* API 응답 → Partner 타입 변환
|
||||
*/
|
||||
function transformPartner(apiData: ApiPartner): Partner {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
partnerCode: apiData.client_code || '',
|
||||
businessNumber: apiData.business_no || '',
|
||||
partnerName: apiData.name || '',
|
||||
representative: apiData.contact_person || '',
|
||||
partnerType: transformClientType(apiData.client_type),
|
||||
businessType: apiData.business_type || '',
|
||||
businessCategory: apiData.business_item || '',
|
||||
zipCode: '',
|
||||
address1: apiData.address || '',
|
||||
address2: '',
|
||||
phone: apiData.phone || '',
|
||||
mobile: apiData.mobile || '',
|
||||
fax: apiData.fax || '',
|
||||
email: apiData.email || '',
|
||||
manager: apiData.manager_name || '',
|
||||
managerPhone: apiData.manager_tel || '',
|
||||
systemManager: apiData.system_manager || '',
|
||||
logoUrl: null,
|
||||
logoBlob: null,
|
||||
salesPaymentDay: 0,
|
||||
creditRating: '',
|
||||
transactionGrade: '',
|
||||
taxInvoiceEmail: apiData.email || '',
|
||||
outstandingAmount: apiData.outstanding_amount || 0,
|
||||
overdueDays: apiData.is_overdue ? 30 : 0,
|
||||
overdueToggle: apiData.is_overdue,
|
||||
badDebtToggle: apiData.has_bad_debt,
|
||||
memos: [],
|
||||
documents: [],
|
||||
category: getPartnerTypeLabel(apiData.client_type),
|
||||
paymentDay: 0,
|
||||
isBadDebt: apiData.has_bad_debt,
|
||||
isActive: apiData.is_active !== false,
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PartnerFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformPartnerToApi(data: PartnerFormData): Record<string, unknown> {
|
||||
return {
|
||||
business_no: data.businessNumber,
|
||||
name: data.partnerName,
|
||||
contact_person: data.representative,
|
||||
client_type: transformPartnerType(data.partnerType),
|
||||
business_type: data.businessType,
|
||||
business_item: data.businessCategory,
|
||||
address: data.address1 + (data.address2 ? ` ${data.address2}` : ''),
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
fax: data.fax,
|
||||
email: data.email,
|
||||
manager_name: data.manager,
|
||||
manager_tel: data.managerPhone,
|
||||
system_manager: data.systemManager,
|
||||
is_overdue: data.overdueToggle,
|
||||
is_active: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 거래처 목록 조회
|
||||
* GET /api/v1/clients
|
||||
*/
|
||||
export async function getPartnerList(filter?: PartnerFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: PartnerListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
let filtered = [...mockPartners];
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(p) =>
|
||||
p.partnerName.toLowerCase().includes(search) ||
|
||||
p.partnerCode.toLowerCase().includes(search) ||
|
||||
p.representative.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
// 검색어
|
||||
if (filter?.search) queryParams.q = filter.search;
|
||||
|
||||
// 악성채권 필터
|
||||
// 페이지네이션
|
||||
if (filter?.page) queryParams.page = String(filter.page);
|
||||
if (filter?.size) queryParams.size = String(filter.size);
|
||||
|
||||
// API 응답 구조: { success, data: { data: [...], current_page, per_page, total, last_page } }
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: {
|
||||
data: ApiPartner[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
};
|
||||
}>('/clients', { params: queryParams });
|
||||
|
||||
const paginated = response.data;
|
||||
let items = (paginated?.data || []).map(transformPartner);
|
||||
|
||||
// 악성채권 필터 (프론트엔드에서 처리)
|
||||
if (filter?.badDebtFilter && filter.badDebtFilter !== 'all') {
|
||||
filtered = filtered.filter((p) =>
|
||||
filter.badDebtFilter === 'badDebt' ? p.isBadDebt : !p.isBadDebt
|
||||
);
|
||||
items = items.filter((p) => (filter.badDebtFilter === 'badDebt' ? p.isBadDebt : !p.isBadDebt));
|
||||
}
|
||||
|
||||
// 정렬
|
||||
// 정렬 (프론트엔드에서 처리)
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
items.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
items.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'nameAsc':
|
||||
filtered.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
|
||||
items.sort((a, b) => a.partnerName.localeCompare(b.partnerName));
|
||||
break;
|
||||
case 'nameDesc':
|
||||
filtered.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
|
||||
items.sort((a, b) => b.partnerName.localeCompare(a.partnerName));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
items,
|
||||
total: paginated?.total || 0,
|
||||
page: paginated?.current_page || 1,
|
||||
size: paginated?.per_page || 20,
|
||||
totalPages: paginated?.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getPartnerList error:', error);
|
||||
return { success: false, error: '거래처 목록 조회에 실패했습니다.' };
|
||||
console.error('거래처 목록 조회 오류:', error);
|
||||
return { success: false, error: '거래처 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 상세 조회
|
||||
export async function getPartner(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: Partner; error?: string }> {
|
||||
/**
|
||||
* 거래처 상세 조회
|
||||
* GET /api/v1/clients/{id}
|
||||
*/
|
||||
export async function getPartner(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Partner;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
const partner = mockPartners.find((p) => p.id === id);
|
||||
|
||||
if (!partner) {
|
||||
return { success: false, error: '거래처를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: partner };
|
||||
// API 응답 구조: { success, data: {...single item} }
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiPartner }>(`/clients/${id}`);
|
||||
return { success: true, data: transformPartner(response.data) };
|
||||
} catch (error) {
|
||||
console.error('getPartner error:', error);
|
||||
return { success: false, error: '거래처 조회에 실패했습니다.' };
|
||||
console.error('거래처 조회 오류:', error);
|
||||
return { success: false, error: '거래처를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 등록
|
||||
export async function createPartner(
|
||||
data: PartnerFormData
|
||||
): Promise<{ success: boolean; data?: Partner; error?: string }> {
|
||||
/**
|
||||
* 거래처 등록
|
||||
* POST /api/v1/clients
|
||||
*/
|
||||
export async function createPartner(data: PartnerFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Partner;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Create partner:', data);
|
||||
|
||||
const newPartner: Partner = {
|
||||
id: String(Date.now()),
|
||||
partnerCode: `P-${String(mockPartners.length + 1).padStart(3, '0')}`,
|
||||
businessNumber: data.businessNumber,
|
||||
partnerName: data.partnerName,
|
||||
representative: data.representative,
|
||||
partnerType: data.partnerType,
|
||||
businessType: data.businessType,
|
||||
businessCategory: data.businessCategory,
|
||||
zipCode: data.zipCode,
|
||||
address1: data.address1,
|
||||
address2: data.address2,
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
fax: data.fax,
|
||||
email: data.email,
|
||||
manager: data.manager,
|
||||
managerPhone: data.managerPhone,
|
||||
systemManager: data.systemManager,
|
||||
logoUrl: data.logoUrl,
|
||||
logoBlob: data.logoBlob,
|
||||
salesPaymentDay: data.salesPaymentDay,
|
||||
creditRating: data.creditRating,
|
||||
transactionGrade: data.transactionGrade,
|
||||
taxInvoiceEmail: data.taxInvoiceEmail,
|
||||
outstandingAmount: data.outstandingAmount,
|
||||
overdueDays: data.overdueDays,
|
||||
overdueToggle: data.overdueToggle,
|
||||
badDebtToggle: data.badDebtToggle,
|
||||
memos: data.memos,
|
||||
documents: data.documents,
|
||||
category: data.category,
|
||||
paymentDay: data.salesPaymentDay,
|
||||
isBadDebt: data.badDebtToggle,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { success: true, data: newPartner };
|
||||
const apiData = transformPartnerToApi(data);
|
||||
// API 응답 구조: { success, data: {...created item} }
|
||||
const response = await apiClient.post<{ success: boolean; data: ApiPartner }>('/clients', apiData);
|
||||
return { success: true, data: transformPartner(response.data) };
|
||||
} catch (error) {
|
||||
console.error('createPartner error:', error);
|
||||
console.error('거래처 등록 오류:', error);
|
||||
return { success: false, error: '거래처 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 수정
|
||||
export async function updatePartner(
|
||||
id: string,
|
||||
data: PartnerFormData
|
||||
): Promise<{ success: boolean; data?: Partner; error?: string }> {
|
||||
/**
|
||||
* 거래처 수정
|
||||
* PUT /api/v1/clients/{id}
|
||||
*/
|
||||
export async function updatePartner(id: string, data: PartnerFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Partner;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Update partner:', id, data);
|
||||
|
||||
const existingPartner = mockPartners.find((p) => p.id === id);
|
||||
if (!existingPartner) {
|
||||
return { success: false, error: '거래처를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const updatedPartner: Partner = {
|
||||
...existingPartner,
|
||||
businessNumber: data.businessNumber,
|
||||
partnerName: data.partnerName,
|
||||
representative: data.representative,
|
||||
partnerType: data.partnerType,
|
||||
businessType: data.businessType,
|
||||
businessCategory: data.businessCategory,
|
||||
zipCode: data.zipCode,
|
||||
address1: data.address1,
|
||||
address2: data.address2,
|
||||
phone: data.phone,
|
||||
mobile: data.mobile,
|
||||
fax: data.fax,
|
||||
email: data.email,
|
||||
manager: data.manager,
|
||||
managerPhone: data.managerPhone,
|
||||
systemManager: data.systemManager,
|
||||
logoUrl: data.logoUrl,
|
||||
logoBlob: data.logoBlob,
|
||||
salesPaymentDay: data.salesPaymentDay,
|
||||
creditRating: data.creditRating,
|
||||
transactionGrade: data.transactionGrade,
|
||||
taxInvoiceEmail: data.taxInvoiceEmail,
|
||||
outstandingAmount: data.outstandingAmount,
|
||||
overdueDays: data.overdueDays,
|
||||
overdueToggle: data.overdueToggle,
|
||||
badDebtToggle: data.badDebtToggle,
|
||||
memos: data.memos,
|
||||
documents: data.documents,
|
||||
category: data.category,
|
||||
paymentDay: data.salesPaymentDay,
|
||||
isBadDebt: data.badDebtToggle,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { success: true, data: updatedPartner };
|
||||
const apiData = transformPartnerToApi(data);
|
||||
// API 응답 구조: { success, data: {...updated item} }
|
||||
const response = await apiClient.put<{ success: boolean; data: ApiPartner }>(`/clients/${id}`, apiData);
|
||||
return { success: true, data: transformPartner(response.data) };
|
||||
} catch (error) {
|
||||
console.error('updatePartner error:', error);
|
||||
console.error('거래처 수정 오류:', error);
|
||||
return { success: false, error: '거래처 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 통계 조회
|
||||
export async function getPartnerStats(): Promise<{ success: boolean; data?: PartnerStats; error?: string }> {
|
||||
/**
|
||||
* 거래처 통계 조회
|
||||
* GET /api/v1/clients/stats
|
||||
*/
|
||||
export async function getPartnerStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: PartnerStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
const total = mockPartners.length;
|
||||
const badDebt = mockPartners.filter((p) => p.isBadDebt).length;
|
||||
// API 응답 구조: { success, data: {...stats} }
|
||||
const response = await apiClient.get<{ success: boolean; data: ApiPartnerStats }>('/clients/stats');
|
||||
const stats = response.data;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
unregistered: 5, // 목업
|
||||
badDebt,
|
||||
normal: total - badDebt,
|
||||
total: stats?.total || 0,
|
||||
unregistered: 0,
|
||||
badDebt: stats?.badDebt || 0,
|
||||
normal: stats?.normal || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getPartnerStats error:', error);
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
console.error('거래처 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 삭제
|
||||
export async function deletePartner(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
/**
|
||||
* 거래처 삭제
|
||||
* DELETE /api/v1/clients/{id}
|
||||
*/
|
||||
export async function deletePartner(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Delete partner:', id);
|
||||
await apiClient.delete(`/clients/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deletePartner error:', error);
|
||||
console.error('거래처 삭제 오류:', error);
|
||||
return { success: false, error: '거래처 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 거래처 일괄 삭제
|
||||
export async function deletePartners(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
/**
|
||||
* 거래처 일괄 삭제
|
||||
* DELETE /api/v1/clients/bulk
|
||||
*/
|
||||
export async function deletePartners(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: 실제 API 호출
|
||||
console.log('Delete partners:', ids);
|
||||
await apiClient.delete('/clients/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deletePartners error:', error);
|
||||
console.error('거래처 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -1,388 +1,378 @@
|
||||
'use server';
|
||||
|
||||
import type { Pricing, PricingStats } from './types';
|
||||
import type {
|
||||
Pricing,
|
||||
PricingStats,
|
||||
PricingListResponse,
|
||||
PricingFilter,
|
||||
PricingFormData,
|
||||
} from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// ===== 목데이터 =====
|
||||
const mockPricingList: Pricing[] = [
|
||||
{
|
||||
id: '1',
|
||||
pricingNumber: 'PRC-2026-001',
|
||||
itemType: '박스',
|
||||
category: '슬라이드 OPEN 사이즈',
|
||||
itemName: '슬라이드 도어 세트',
|
||||
spec: '1200x2400',
|
||||
orderItems: [
|
||||
{ id: 'oi1', name: '무게', value: '400KG' },
|
||||
{ id: 'oi2', name: '두께', value: '50mm' },
|
||||
],
|
||||
unit: 'SET',
|
||||
division: '일반',
|
||||
vendor: '(주)슬라이드텍',
|
||||
purchasePrice: 850000,
|
||||
marginRate: 15,
|
||||
sellingPrice: 977500,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-02',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
pricingNumber: 'PRC-2026-002',
|
||||
itemType: '부속',
|
||||
category: '모터',
|
||||
itemName: '서보모터 750W',
|
||||
spec: 'AC220V',
|
||||
orderItems: [
|
||||
{ id: 'oi3', name: '무게', value: '12KG' },
|
||||
],
|
||||
unit: 'EA',
|
||||
division: '일반',
|
||||
vendor: '삼성전기',
|
||||
purchasePrice: 320000,
|
||||
marginRate: 20,
|
||||
sellingPrice: 384000,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-02',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
pricingNumber: 'PRC-2026-003',
|
||||
itemType: '소모품',
|
||||
category: '공정자재',
|
||||
itemName: '용접봉 E7016',
|
||||
spec: '4.0mm x 350mm',
|
||||
orderItems: [
|
||||
{ id: 'oi4', name: '무게', value: '5KG' },
|
||||
],
|
||||
unit: 'BOX',
|
||||
division: '일반',
|
||||
vendor: '현대용접산업',
|
||||
purchasePrice: 45000,
|
||||
marginRate: 25,
|
||||
sellingPrice: 56250,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-03',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
pricingNumber: 'PRC-2026-004',
|
||||
itemType: '공과',
|
||||
category: '철물',
|
||||
itemName: '앵커볼트 세트',
|
||||
spec: 'M12 x 100',
|
||||
orderItems: [
|
||||
{ id: 'oi5', name: '무게', value: '500G' },
|
||||
],
|
||||
unit: 'SET',
|
||||
division: '특수',
|
||||
vendor: '철강볼트',
|
||||
purchasePrice: 12000,
|
||||
marginRate: 30,
|
||||
sellingPrice: 15600,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-03',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
pricingNumber: 'PRC-2026-005',
|
||||
itemType: '박스',
|
||||
category: '슬라이드 OPEN 사이즈',
|
||||
itemName: '자동문 프레임',
|
||||
spec: '900x2100',
|
||||
orderItems: [
|
||||
{ id: 'oi6', name: '무게', value: '280KG' },
|
||||
{ id: 'oi7', name: '두께', value: '40mm' },
|
||||
],
|
||||
unit: 'SET',
|
||||
division: '일반',
|
||||
vendor: '(주)슬라이드텍',
|
||||
purchasePrice: 650000,
|
||||
marginRate: 18,
|
||||
sellingPrice: 767000,
|
||||
status: 'not_registered',
|
||||
createdAt: '2026-01-04',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
pricingNumber: 'PRC-2026-006',
|
||||
itemType: '부속',
|
||||
category: '모터',
|
||||
itemName: '기어드모터 1.5KW',
|
||||
spec: 'AC380V',
|
||||
orderItems: [
|
||||
{ id: 'oi8', name: '무게', value: '25KG' },
|
||||
],
|
||||
unit: 'EA',
|
||||
division: '특수',
|
||||
vendor: '삼성전기',
|
||||
purchasePrice: 580000,
|
||||
marginRate: 22,
|
||||
sellingPrice: 707600,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-04',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
pricingNumber: 'PRC-2026-007',
|
||||
itemType: '소모품',
|
||||
category: '공정자재',
|
||||
itemName: '절삭유 WS-300',
|
||||
spec: '20L',
|
||||
orderItems: [],
|
||||
unit: 'CAN',
|
||||
division: '일반',
|
||||
vendor: '한국윤활유',
|
||||
purchasePrice: 85000,
|
||||
marginRate: 15,
|
||||
sellingPrice: 97750,
|
||||
status: 'not_registered',
|
||||
createdAt: '2026-01-05',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
pricingNumber: 'PRC-2026-008',
|
||||
itemType: '공과',
|
||||
category: '철물',
|
||||
itemName: '스테인레스 볼트',
|
||||
spec: 'M10 x 50',
|
||||
orderItems: [
|
||||
{ id: 'oi9', name: '무게', value: '200G' },
|
||||
],
|
||||
unit: 'BOX',
|
||||
division: '일반',
|
||||
vendor: '철강볼트',
|
||||
purchasePrice: 35000,
|
||||
marginRate: 28,
|
||||
sellingPrice: 44800,
|
||||
status: 'in_use',
|
||||
createdAt: '2026-01-05',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 단가관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// ===== 단가 목록 조회 =====
|
||||
export async function getPricingList(params?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
itemType?: string;
|
||||
category?: string;
|
||||
spec?: string;
|
||||
division?: string;
|
||||
status?: string;
|
||||
sort?: string;
|
||||
search?: string;
|
||||
}): Promise<{
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiPricing {
|
||||
id: number;
|
||||
pricing_number: string;
|
||||
item_type: string | null;
|
||||
category: string | null;
|
||||
item_name: string;
|
||||
spec: string | null;
|
||||
order_items: ApiOrderItem[] | null;
|
||||
unit: string | null;
|
||||
division: string | null;
|
||||
vendor: string | null;
|
||||
vendor_id: number | null;
|
||||
purchase_price: number;
|
||||
margin_rate: number;
|
||||
selling_price: number;
|
||||
status: 'in_use' | 'stopped' | 'not_registered';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiOrderItem {
|
||||
id: number;
|
||||
name: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface ApiPricingStats {
|
||||
total: number;
|
||||
in_use: number;
|
||||
stopped: number;
|
||||
not_registered: number;
|
||||
}
|
||||
|
||||
interface ApiVendor {
|
||||
id: number;
|
||||
name: string;
|
||||
business_no: string | null;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Pricing 타입 변환
|
||||
*/
|
||||
function transformPricing(apiData: ApiPricing): Pricing {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
pricingNumber: apiData.pricing_number || '',
|
||||
itemType: apiData.item_type || '',
|
||||
category: apiData.category || '',
|
||||
itemName: apiData.item_name || '',
|
||||
spec: apiData.spec || '',
|
||||
orderItems: (apiData.order_items || []).map((item) => ({
|
||||
id: String(item.id),
|
||||
name: item.name || '',
|
||||
value: item.value || '',
|
||||
})),
|
||||
unit: apiData.unit || '',
|
||||
division: apiData.division || '',
|
||||
vendor: apiData.vendor || '',
|
||||
purchasePrice: apiData.purchase_price || 0,
|
||||
marginRate: apiData.margin_rate || 0,
|
||||
sellingPrice: apiData.selling_price || 0,
|
||||
status: apiData.status || 'not_registered',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PricingFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiRequest(data: Partial<PricingFormData>): Record<string, unknown> {
|
||||
const apiData: Record<string, unknown> = {};
|
||||
|
||||
if (data.itemType !== undefined) apiData.item_type = data.itemType || null;
|
||||
if (data.category !== undefined) apiData.category = data.category || null;
|
||||
if (data.itemName !== undefined) apiData.item_name = data.itemName;
|
||||
if (data.spec !== undefined) apiData.spec = data.spec || null;
|
||||
if (data.unit !== undefined) apiData.unit = data.unit || null;
|
||||
if (data.division !== undefined) apiData.division = data.division || null;
|
||||
if (data.vendor !== undefined) apiData.vendor = data.vendor || null;
|
||||
if (data.purchasePrice !== undefined) apiData.purchase_price = data.purchasePrice;
|
||||
if (data.marginRate !== undefined) apiData.margin_rate = data.marginRate;
|
||||
if (data.sellingPrice !== undefined) apiData.selling_price = data.sellingPrice;
|
||||
if (data.status !== undefined) apiData.status = data.status;
|
||||
|
||||
// 주문 항목 변환
|
||||
if (data.orderItems !== undefined) {
|
||||
apiData.order_items = data.orderItems.map((item) => ({
|
||||
name: item.name,
|
||||
value: item.value,
|
||||
}));
|
||||
}
|
||||
|
||||
return apiData;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 단가 목록 조회
|
||||
* GET /api/v1/pricing
|
||||
*/
|
||||
export async function getPricingList(filter?: PricingFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: { items: Pricing[]; total: number };
|
||||
data?: PricingListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let filtered = [...mockPricingList];
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 품목유형 필터
|
||||
if (params?.itemType && params.itemType !== 'all') {
|
||||
const typeMap: Record<string, string> = {
|
||||
box: '박스',
|
||||
parts: '부속',
|
||||
consumables: '소모품',
|
||||
utility: '공과',
|
||||
};
|
||||
filtered = filtered.filter(p => p.itemType === typeMap[params.itemType!]);
|
||||
}
|
||||
// 검색
|
||||
if (filter?.search) queryParams.search = filter.search;
|
||||
|
||||
// 카테고리 필터
|
||||
if (params?.category && params.category !== 'all') {
|
||||
const categoryMap: Record<string, string> = {
|
||||
slide_open: '슬라이드 OPEN 사이즈',
|
||||
motor: '모터',
|
||||
process_material: '공정자재',
|
||||
hardware: '철물',
|
||||
};
|
||||
filtered = filtered.filter(p => p.category === categoryMap[params.category!]);
|
||||
}
|
||||
// 필터
|
||||
if (filter?.status && filter.status !== 'all') queryParams.status = filter.status;
|
||||
if (filter?.itemType && filter.itemType !== 'all') queryParams.item_type = filter.itemType;
|
||||
if (filter?.category && filter.category !== 'all') queryParams.category = filter.category;
|
||||
if (filter?.division && filter.division !== 'all') queryParams.division = filter.division;
|
||||
|
||||
// 구분 필터
|
||||
if (params?.division && params.division !== 'all') {
|
||||
const divisionMap: Record<string, string> = {
|
||||
general: '일반',
|
||||
special: '특수',
|
||||
};
|
||||
filtered = filtered.filter(p => p.division === divisionMap[params.division!]);
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (params?.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filtered = filtered.filter(p =>
|
||||
p.pricingNumber.toLowerCase().includes(search) ||
|
||||
p.itemName.toLowerCase().includes(search) ||
|
||||
p.category.toLowerCase().includes(search) ||
|
||||
p.vendor.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
// 페이지네이션
|
||||
if (filter?.page) queryParams.page = String(filter.page);
|
||||
if (filter?.size) queryParams.per_page = String(filter.size);
|
||||
|
||||
// 정렬
|
||||
if (params?.sort === 'oldest') {
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else if (params?.sort === 'price_high') {
|
||||
filtered.sort((a, b) => b.sellingPrice - a.sellingPrice);
|
||||
} else if (params?.sort === 'price_low') {
|
||||
filtered.sort((a, b) => a.sellingPrice - b.sellingPrice);
|
||||
} else {
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
if (filter?.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
latest: { field: 'created_at', dir: 'desc' },
|
||||
oldest: { field: 'created_at', dir: 'asc' },
|
||||
itemNameAsc: { field: 'item_name', dir: 'asc' },
|
||||
itemNameDesc: { field: 'item_name', dir: 'desc' },
|
||||
priceAsc: { field: 'selling_price', dir: 'asc' },
|
||||
priceDesc: { field: 'selling_price', dir: 'desc' },
|
||||
price_high: { field: 'selling_price', dir: 'desc' },
|
||||
price_low: { field: 'selling_price', dir: 'asc' },
|
||||
};
|
||||
const sort = sortMap[filter.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiPricing[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/pricing', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformPricing);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: filtered,
|
||||
total: filtered.length,
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[getPricingList] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 목록 조회 오류:', error);
|
||||
return { success: false, error: '단가 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 통계 조회 =====
|
||||
/**
|
||||
* 단가 통계 조회
|
||||
* GET /api/v1/pricing/stats
|
||||
*/
|
||||
export async function getPricingStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: PricingStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const stats: PricingStats = {
|
||||
total: mockPricingList.length,
|
||||
inUse: mockPricingList.filter(p => p.status === 'in_use').length,
|
||||
notRegistered: mockPricingList.filter(p => p.status === 'not_registered').length,
|
||||
const response = await apiClient.get<ApiPricingStats>('/pricing/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: response.total || 0,
|
||||
inUse: response.in_use || 0,
|
||||
notRegistered: response.not_registered || 0,
|
||||
},
|
||||
};
|
||||
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
console.error('[getPricingStats] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 통계 조회 오류:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 단일 삭제 =====
|
||||
export async function deletePricing(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목데이터에서는 실제 삭제하지 않음
|
||||
const index = mockPricingList.findIndex(p => p.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '단가를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[deletePricing] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 일괄 삭제 =====
|
||||
export async function deletePricings(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('[deletePricings] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 단가 상세 조회 =====
|
||||
/**
|
||||
* 단가 상세 조회
|
||||
* GET /api/v1/pricing/{id}
|
||||
*/
|
||||
export async function getPricingDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const pricing = mockPricingList.find(p => p.id === id);
|
||||
if (!pricing) {
|
||||
return { success: false, error: '단가를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: pricing };
|
||||
const response = await apiClient.get<ApiPricing>(`/pricing/${id}`);
|
||||
return { success: true, data: transformPricing(response) };
|
||||
} catch (error) {
|
||||
console.error('[getPricingDetail] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 상세 조회 오류:', error);
|
||||
return { success: false, error: '단가 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 단가 생성 =====
|
||||
export async function createPricing(data: Omit<Pricing, 'id' | 'pricingNumber' | 'createdAt'>): Promise<{
|
||||
/**
|
||||
* 단가 등록
|
||||
* POST /api/v1/pricing
|
||||
*/
|
||||
export async function createPricing(data: PricingFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const newId = String(mockPricingList.length + 1);
|
||||
const newPricingNumber = `PRC-2026-${String(mockPricingList.length + 1).padStart(3, '0')}`;
|
||||
|
||||
const newPricing: Pricing = {
|
||||
...data,
|
||||
id: newId,
|
||||
pricingNumber: newPricingNumber,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
// 목데이터에는 추가하지 않음 (실제 API 연동 시 DB에 저장)
|
||||
return { success: true, data: newPricing };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.post<ApiPricing>('/pricing', apiData);
|
||||
return { success: true, data: transformPricing(response) };
|
||||
} catch (error) {
|
||||
console.error('[createPricing] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 등록 오류:', error);
|
||||
return { success: false, error: '단가 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 단가 수정 =====
|
||||
export async function updatePricing(id: string, data: Partial<Pricing>): Promise<{
|
||||
/**
|
||||
* 단가 수정
|
||||
* PUT /api/v1/pricing/{id}
|
||||
*/
|
||||
export async function updatePricing(
|
||||
id: string,
|
||||
data: Partial<PricingFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockPricingList.findIndex(p => p.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '단가를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const updatedPricing: Pricing = {
|
||||
...mockPricingList[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString().split('T')[0],
|
||||
};
|
||||
|
||||
// 목데이터에는 수정하지 않음 (실제 API 연동 시 DB에 업데이트)
|
||||
return { success: true, data: updatedPricing };
|
||||
const apiData = transformToApiRequest(data);
|
||||
const response = await apiClient.put<ApiPricing>(`/pricing/${id}`, apiData);
|
||||
return { success: true, data: transformPricing(response) };
|
||||
} catch (error) {
|
||||
console.error('[updatePricing] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('단가 수정 오류:', error);
|
||||
return { success: false, error: '단가 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 거래처 목록 조회 (발주처) =====
|
||||
/**
|
||||
* 단가 삭제
|
||||
* DELETE /api/v1/pricing/{id}
|
||||
*/
|
||||
export async function deletePricing(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.delete(`/pricing/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('단가 삭제 오류:', error);
|
||||
return { success: false, error: '단가 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 일괄 삭제
|
||||
* DELETE /api/v1/pricing/bulk
|
||||
*/
|
||||
export async function deletePricings(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await apiClient.delete('/pricing/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('단가 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 거래처(벤더) 목록 조회
|
||||
* GET /api/v1/clients (거래처 API 재사용)
|
||||
*/
|
||||
export async function getVendorList(): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string; name: string }[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목데이터에서 거래처 추출
|
||||
const vendors = [
|
||||
{ id: '1', name: '(주)슬라이드텍' },
|
||||
{ id: '2', name: '삼성전기' },
|
||||
{ id: '3', name: '현대용접산업' },
|
||||
{ id: '4', name: '철강볼트' },
|
||||
{ id: '5', name: '한국윤활유' },
|
||||
];
|
||||
const response = await apiClient.get<{
|
||||
data: ApiVendor[];
|
||||
}>('/clients', { params: { per_page: '100' } });
|
||||
|
||||
const vendors = (response.data || []).map((v) => ({
|
||||
id: String(v.id),
|
||||
name: v.name || '',
|
||||
}));
|
||||
|
||||
return { success: true, data: vendors };
|
||||
} catch (error) {
|
||||
console.error('[getVendorList] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
console.error('거래처 목록 조회 오류:', error);
|
||||
return { success: false, error: '거래처 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 확정
|
||||
* POST /api/v1/pricing/{id}/finalize
|
||||
*/
|
||||
export async function finalizePricing(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.post<ApiPricing>(`/pricing/${id}/finalize`);
|
||||
return { success: true, data: transformPricing(response) };
|
||||
} catch (error) {
|
||||
console.error('단가 확정 오류:', error);
|
||||
return { success: false, error: '단가 확정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 단가 변경이력 조회
|
||||
* GET /api/v1/pricing/{id}/revisions
|
||||
*/
|
||||
export async function getPricingRevisions(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Pricing[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get<{ data: ApiPricing[] }>(`/pricing/${id}/revisions`);
|
||||
const revisions = (response.data || []).map(transformPricing);
|
||||
return { success: true, data: revisions };
|
||||
} catch (error) {
|
||||
console.error('단가 변경이력 조회 오류:', error);
|
||||
return { success: false, error: '변경이력을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,44 @@ export interface PricingStats {
|
||||
notRegistered: number; // 미등록 단가
|
||||
}
|
||||
|
||||
// 목록 응답
|
||||
export interface PricingListResponse {
|
||||
items: Pricing[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 필터 파라미터
|
||||
export interface PricingFilter {
|
||||
search?: string;
|
||||
status?: string;
|
||||
itemType?: string;
|
||||
category?: string;
|
||||
division?: string;
|
||||
spec?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
// 폼 데이터
|
||||
export interface PricingFormData {
|
||||
itemType: string;
|
||||
category: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
orderItems: OrderItem[];
|
||||
unit: string;
|
||||
division: string;
|
||||
vendor: string;
|
||||
purchasePrice: number;
|
||||
marginRate: number;
|
||||
sellingPrice: number;
|
||||
status: PricingStatus;
|
||||
}
|
||||
|
||||
// ===== 필터 옵션 =====
|
||||
|
||||
// 품목유형 옵션
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download, List } from 'lucide-react';
|
||||
import { Calendar, Plus, X, Loader2, Upload, FileText, Mic, Download, List, Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -29,7 +29,7 @@ import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { TimePicker } from '@/components/ui/time-picker';
|
||||
import { toast } from 'sonner';
|
||||
import type { SiteBriefing, SiteBriefingFormData, ParticipatingCompany, BriefingDocument } from './types';
|
||||
import type { SiteBriefing, SiteBriefingFormData, ParticipatingCompany, BriefingDocument, AttendeeItem } from './types';
|
||||
import {
|
||||
BRIEFING_TYPE_OPTIONS,
|
||||
ATTENDANCE_STATUS_OPTIONS,
|
||||
@@ -37,20 +37,28 @@ import {
|
||||
getEmptySiteBriefingFormData,
|
||||
siteBriefingToFormData,
|
||||
} from './types';
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: '1', label: '회사명' },
|
||||
{ value: '2', label: '대한건설' },
|
||||
{ value: '3', label: '삼성시공' },
|
||||
];
|
||||
|
||||
// 목업 참석자 목록
|
||||
const MOCK_ATTENDEES = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from '@/components/ui/command';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { createSiteBriefing, updateSiteBriefing, deleteSiteBriefing } from './actions';
|
||||
import { getPartnerList } from '../partners/actions';
|
||||
import { getSiteList, createSite } from '../site-management/actions';
|
||||
import { getEmployees } from '@/components/hr/EmployeeManagement/actions';
|
||||
import type { Partner } from '../partners/types';
|
||||
import type { Site } from '../site-management/types';
|
||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||
|
||||
// 목업 문서 목록 (상세 모드에서 다운로드 버튼 테스트용)
|
||||
const MOCK_DOCUMENTS: BriefingDocument[] = [
|
||||
@@ -89,10 +97,16 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
const isNewMode = mode === 'new';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// DEBUG: 초기 데이터 확인
|
||||
console.log('[SiteBriefingForm] initialData:', initialData);
|
||||
console.log('[SiteBriefingForm] initialData.attendee:', initialData?.attendee);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<SiteBriefingFormData>(
|
||||
initialData ? siteBriefingToFormData(initialData) : getEmptySiteBriefingFormData()
|
||||
);
|
||||
const [formData, setFormData] = useState<SiteBriefingFormData>(() => {
|
||||
const data = initialData ? siteBriefingToFormData(initialData) : getEmptySiteBriefingFormData();
|
||||
console.log('[SiteBriefingForm] formData.attendeeItems:', data.attendeeItems);
|
||||
return data;
|
||||
});
|
||||
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -107,6 +121,63 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// 거래처 목록
|
||||
const [partners, setPartners] = useState<Partner[]>([]);
|
||||
const [isLoadingPartners, setIsLoadingPartners] = useState(false);
|
||||
|
||||
// 현장 목록 (선택된 거래처 기준)
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
const [isLoadingSites, setIsLoadingSites] = useState(false);
|
||||
|
||||
// 현장 입력 및 선택
|
||||
const [siteInputValue, setSiteInputValue] = useState(formData.projectName);
|
||||
const [showSiteDropdown, setShowSiteDropdown] = useState(false);
|
||||
const siteInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 현장 신규 등록 다이얼로그
|
||||
const [showNewSiteDialog, setShowNewSiteDialog] = useState(false);
|
||||
const [newSiteName, setNewSiteName] = useState('');
|
||||
const [isCreatingSite, setIsCreatingSite] = useState(false);
|
||||
|
||||
// 직원 목록 (참석자용)
|
||||
const [employees, setEmployees] = useState<Employee[]>([]);
|
||||
|
||||
// 참석자 Multi-Select Combobox 상태
|
||||
const [attendeePopoverOpen, setAttendeePopoverOpen] = useState(false);
|
||||
const [attendeeSearchValue, setAttendeeSearchValue] = useState('');
|
||||
|
||||
// 필드 변경 핸들러 (참석자 핸들러에서 사용하므로 먼저 선언)
|
||||
const handleChange = useCallback((field: keyof SiteBriefingFormData, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 참석자 선택 핸들러
|
||||
const handleAttendeeSelect = useCallback((employee: Employee) => {
|
||||
const newItem: AttendeeItem = { id: employee.id, name: employee.name };
|
||||
const exists = formData.attendeeItems.some((item) => item.id === employee.id);
|
||||
if (!exists) {
|
||||
handleChange('attendeeItems', [...formData.attendeeItems, newItem]);
|
||||
}
|
||||
setAttendeeSearchValue('');
|
||||
}, [formData.attendeeItems, handleChange]);
|
||||
|
||||
// 참석자 제거 핸들러
|
||||
const handleAttendeeRemove = useCallback((attendeeId: string) => {
|
||||
handleChange('attendeeItems', formData.attendeeItems.filter((item) => item.id !== attendeeId && item.name !== attendeeId));
|
||||
}, [formData.attendeeItems, handleChange]);
|
||||
|
||||
// 참석자 직접 입력 추가 핸들러
|
||||
const handleAttendeeAdd = useCallback(() => {
|
||||
const trimmed = attendeeSearchValue.trim();
|
||||
if (!trimmed) return;
|
||||
const exists = formData.attendeeItems.some((item) => item.name === trimmed);
|
||||
if (!exists) {
|
||||
const newItem: AttendeeItem = { id: '', name: trimmed };
|
||||
handleChange('attendeeItems', [...formData.attendeeItems, newItem]);
|
||||
}
|
||||
setAttendeeSearchValue('');
|
||||
}, [attendeeSearchValue, formData.attendeeItems, handleChange]);
|
||||
|
||||
// 상세/수정 모드에서 목데이터 초기화
|
||||
useEffect(() => {
|
||||
if (initialData && formData.documents.length === 0) {
|
||||
@@ -117,9 +188,66 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback((field: keyof SiteBriefingFormData, value: unknown) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
// 거래처 목록 로드
|
||||
useEffect(() => {
|
||||
const loadPartners = async () => {
|
||||
setIsLoadingPartners(true);
|
||||
try {
|
||||
const result = await getPartnerList({ size: 100 });
|
||||
if (result.success && result.data) {
|
||||
setPartners(result.data.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('거래처 목록 로드 실패:', error);
|
||||
} finally {
|
||||
setIsLoadingPartners(false);
|
||||
}
|
||||
};
|
||||
loadPartners();
|
||||
}, []);
|
||||
|
||||
// 거래처 선택 시 현장 목록 로드
|
||||
useEffect(() => {
|
||||
const loadSites = async () => {
|
||||
if (!formData.partnerId) {
|
||||
setSites([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingSites(true);
|
||||
try {
|
||||
const result = await getSiteList({ clientId: formData.partnerId, size: 100 });
|
||||
if (result.success && result.data) {
|
||||
setSites(result.data.items);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('현장 목록 로드 실패:', error);
|
||||
} finally {
|
||||
setIsLoadingSites(false);
|
||||
}
|
||||
};
|
||||
loadSites();
|
||||
}, [formData.partnerId]);
|
||||
|
||||
// 초기 데이터가 있을 때 siteInputValue 동기화
|
||||
useEffect(() => {
|
||||
if (initialData?.title) {
|
||||
setSiteInputValue(initialData.title);
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 직원 목록 로드 (참석자용)
|
||||
useEffect(() => {
|
||||
const loadEmployees = async () => {
|
||||
try {
|
||||
const result = await getEmployees({ status: 'active', per_page: 100 });
|
||||
if (result.data && result.data.length > 0) {
|
||||
setEmployees(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('직원 목록 로드 실패:', error);
|
||||
}
|
||||
};
|
||||
loadEmployees();
|
||||
}, []);
|
||||
|
||||
// 네비게이션 핸들러
|
||||
@@ -151,8 +279,19 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
let result;
|
||||
if (isNewMode) {
|
||||
result = await createSiteBriefing(formData);
|
||||
} else if (briefingId) {
|
||||
result = await updateSiteBriefing(briefingId, formData);
|
||||
} else {
|
||||
throw new Error('현장설명회 ID가 없습니다.');
|
||||
}
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success(isNewMode ? '현장설명회가 등록되었습니다.' : '수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push('/ko/construction/project/bidding/site-briefings');
|
||||
@@ -162,7 +301,7 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isNewMode, router]);
|
||||
}, [isNewMode, briefingId, formData, router]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
@@ -170,10 +309,19 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!briefingId) {
|
||||
toast.error('현장설명회 ID가 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// TODO: 실제 API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
const result = await deleteSiteBriefing(briefingId);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success('현장설명회가 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/construction/project/bidding/site-briefings');
|
||||
@@ -183,7 +331,51 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
}, [briefingId, router]);
|
||||
|
||||
// 현장 신규 등록 핸들러
|
||||
const handleCreateSite = useCallback(async () => {
|
||||
if (!newSiteName.trim()) {
|
||||
toast.error('현장명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.partnerId) {
|
||||
toast.error('거래처를 먼저 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreatingSite(true);
|
||||
try {
|
||||
const result = await createSite({
|
||||
siteName: newSiteName.trim(),
|
||||
partnerId: formData.partnerId,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || '현장 등록에 실패했습니다.');
|
||||
}
|
||||
|
||||
toast.success('현장이 등록되었습니다.');
|
||||
|
||||
// 현장 목록 새로고침
|
||||
const sitesResult = await getSiteList({ clientId: formData.partnerId, size: 100 });
|
||||
if (sitesResult.success && sitesResult.data) {
|
||||
setSites(sitesResult.data.items);
|
||||
}
|
||||
|
||||
// 새로 등록된 현장을 선택
|
||||
setSiteInputValue(newSiteName.trim());
|
||||
handleChange('projectName', newSiteName.trim());
|
||||
|
||||
// 다이얼로그 닫기 및 상태 초기화
|
||||
setShowNewSiteDialog(false);
|
||||
setNewSiteName('');
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '현장 등록에 실패했습니다.');
|
||||
} finally {
|
||||
setIsCreatingSite(false);
|
||||
}
|
||||
}, [newSiteName, formData.partnerId, handleChange]);
|
||||
|
||||
// 참여업체 추가 핸들러
|
||||
const handleAddCompany = useCallback(() => {
|
||||
@@ -451,7 +643,38 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
{renderField('현설번호', 'briefingCode', formData.briefingCode, {
|
||||
placeholder: '123123',
|
||||
})}
|
||||
{renderSelectField('거래처명', 'partnerId', formData.partnerId, MOCK_PARTNERS, true)}
|
||||
{/* 거래처명 - 실제 API 데이터 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
거래처명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.partnerId}
|
||||
onValueChange={(val) => {
|
||||
handleChange('partnerId', val);
|
||||
// 거래처 변경 시 현장명 초기화
|
||||
handleChange('projectName', '');
|
||||
setSiteInputValue('');
|
||||
// partnerName도 업데이트
|
||||
const selectedPartner = partners.find((p) => p.id === val);
|
||||
if (selectedPartner) {
|
||||
handleChange('partnerName', selectedPartner.partnerName);
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode || isLoadingPartners}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder={isLoadingPartners ? '로딩 중...' : '거래처 선택'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{partners.map((partner) => (
|
||||
<SelectItem key={partner.id} value={partner.id}>
|
||||
{partner.partnerName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{renderField('현장설명회 일자', 'briefingDate', formData.briefingDate, {
|
||||
type: 'date',
|
||||
required: true,
|
||||
@@ -471,7 +694,112 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
{renderField('현장설명회 장소', 'location', formData.location, {
|
||||
placeholder: '장소명',
|
||||
})}
|
||||
{renderSelectField('참석자', 'attendee', formData.attendee, MOCK_ATTENDEES)}
|
||||
{/* 참석자 - Multi-Select Combobox */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">참석자</Label>
|
||||
{isViewMode ? (
|
||||
<div className="flex flex-wrap gap-2 min-h-[38px] p-2 border rounded-md bg-gray-50">
|
||||
{formData.attendeeItems.length > 0 ? (
|
||||
formData.attendeeItems.map((item) => (
|
||||
<Badge key={item.id || item.name} variant="secondary">
|
||||
{item.name}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-gray-400 text-sm">참석자 없음</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Popover open={attendeePopoverOpen} onOpenChange={setAttendeePopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={attendeePopoverOpen}
|
||||
className="w-full justify-between min-h-[38px] h-auto"
|
||||
>
|
||||
<div className="flex flex-wrap gap-1 flex-1 text-left">
|
||||
{formData.attendeeItems.length > 0 ? (
|
||||
formData.attendeeItems.map((item) => (
|
||||
<Badge
|
||||
key={item.id || item.name}
|
||||
variant="secondary"
|
||||
className="mr-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAttendeeRemove(item.id || item.name);
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
<X className="ml-1 h-3 w-3 cursor-pointer" />
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-muted-foreground">참석자 선택 또는 직접 입력</span>
|
||||
)}
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder="직원 검색 또는 직접 입력..."
|
||||
value={attendeeSearchValue}
|
||||
onValueChange={setAttendeeSearchValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && attendeeSearchValue.trim()) {
|
||||
e.preventDefault();
|
||||
handleAttendeeAdd();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{attendeeSearchValue.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full px-2 py-1.5 text-sm text-left hover:bg-accent rounded"
|
||||
onClick={handleAttendeeAdd}
|
||||
>
|
||||
"{attendeeSearchValue}" 추가
|
||||
</button>
|
||||
) : (
|
||||
'검색 결과가 없습니다.'
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandGroup heading="직원 목록">
|
||||
{employees
|
||||
.filter((emp) =>
|
||||
emp.name.toLowerCase().includes(attendeeSearchValue.toLowerCase())
|
||||
)
|
||||
.map((employee) => {
|
||||
const isSelected = formData.attendeeItems.some(
|
||||
(item) => item.id === employee.id
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={employee.id}
|
||||
value={employee.name}
|
||||
onSelect={() => handleAttendeeSelect(employee)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
isSelected ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{employee.name}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
{renderSelectField('상태', 'attendanceStatus', formData.attendanceStatus, ATTENDANCE_STATUS_OPTIONS)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -483,10 +811,87 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderField('현장명', 'projectName', formData.projectName, {
|
||||
required: true,
|
||||
placeholder: '현장명',
|
||||
})}
|
||||
{/* 현장명 - 거래처 연동 Combobox */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">
|
||||
현장명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
ref={siteInputRef}
|
||||
type="text"
|
||||
value={siteInputValue}
|
||||
onChange={(e) => {
|
||||
setSiteInputValue(e.target.value);
|
||||
handleChange('projectName', e.target.value);
|
||||
setShowSiteDropdown(true);
|
||||
}}
|
||||
onFocus={() => formData.partnerId && setShowSiteDropdown(true)}
|
||||
onBlur={() => setTimeout(() => setShowSiteDropdown(false), 200)}
|
||||
placeholder={
|
||||
!formData.partnerId
|
||||
? '거래처를 먼저 선택해주세요'
|
||||
: isLoadingSites
|
||||
? '현장 목록 로딩 중...'
|
||||
: '현장명 입력 또는 선택'
|
||||
}
|
||||
disabled={isViewMode || !formData.partnerId}
|
||||
className="bg-white pr-10"
|
||||
/>
|
||||
{!isViewMode && formData.partnerId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7"
|
||||
onClick={() => {
|
||||
setNewSiteName(siteInputValue);
|
||||
setShowNewSiteDialog(true);
|
||||
}}
|
||||
title="현장 신규 등록"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 현장 드롭다운 */}
|
||||
{showSiteDropdown && sites.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border rounded-md shadow-lg max-h-60 overflow-auto">
|
||||
{sites
|
||||
.filter((site) =>
|
||||
site.siteName.toLowerCase().includes(siteInputValue.toLowerCase())
|
||||
)
|
||||
.map((site) => (
|
||||
<button
|
||||
key={site.id}
|
||||
type="button"
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setSiteInputValue(site.siteName);
|
||||
handleChange('projectName', site.siteName);
|
||||
setShowSiteDropdown(false);
|
||||
}}
|
||||
>
|
||||
<div className="font-medium">{site.siteName}</div>
|
||||
{site.address && (
|
||||
<div className="text-xs text-gray-500">{site.address}</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{sites.filter((site) =>
|
||||
site.siteName.toLowerCase().includes(siteInputValue.toLowerCase())
|
||||
).length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-gray-500">
|
||||
일치하는 현장이 없습니다. 신규 등록하려면 + 버튼을 클릭하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!formData.partnerId && !isViewMode && (
|
||||
<p className="text-xs text-amber-600">거래처를 먼저 선택하면 해당 거래처의 현장 목록이 표시됩니다.</p>
|
||||
)}
|
||||
</div>
|
||||
{renderField('입찰일자', 'bidDate', formData.bidDate, {
|
||||
type: 'date',
|
||||
})}
|
||||
@@ -736,6 +1141,53 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 현장 신규 등록 다이얼로그 */}
|
||||
<AlertDialog open={showNewSiteDialog} onOpenChange={setShowNewSiteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>현장 신규 등록</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 거래처에 새로운 현장을 등록합니다.
|
||||
<br />
|
||||
등록된 현장은 현장관리 목록에도 추가됩니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<div className="py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">거래처</Label>
|
||||
<Input
|
||||
value={partners.find((p) => p.id === formData.partnerId)?.partnerName || ''}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 mt-4">
|
||||
<Label className="text-sm font-medium">
|
||||
현장명 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={newSiteName}
|
||||
onChange={(e) => setNewSiteName(e.target.value)}
|
||||
placeholder="현장명을 입력하세요"
|
||||
disabled={isCreatingSite}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isCreatingSite}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleCreateSite}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isCreatingSite || !newSiteName.trim()}
|
||||
>
|
||||
{isCreatingSite && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
등록
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -130,12 +130,11 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setBriefings(listResult.data.items);
|
||||
// 목업 통계 계산 (참석 상태 기준)
|
||||
// 통계 계산 (참석 상태 기준)
|
||||
const items = listResult.data.items;
|
||||
const total = items.length;
|
||||
// 목업: scheduled 상태는 참석예정, 나머지는 참석완료로 처리
|
||||
const scheduled = items.filter((b) => b.status === 'scheduled').length;
|
||||
const attended = items.filter((b) => b.status !== 'scheduled').length;
|
||||
const scheduled = items.filter((b) => b.attendanceStatus === 'scheduled').length;
|
||||
const attended = items.filter((b) => b.attendanceStatus === 'attended').length;
|
||||
setStatsData({ total, scheduled, attended });
|
||||
}
|
||||
} catch {
|
||||
@@ -155,9 +154,9 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
// 필터링된 데이터
|
||||
const filteredBriefings = useMemo(() => {
|
||||
return briefings.filter((briefing) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'scheduled' && briefing.status !== 'scheduled') return false;
|
||||
if (activeStatTab === 'attended' && briefing.status === 'scheduled') return false;
|
||||
// Stats 탭 필터 (참석 상태 기준)
|
||||
if (activeStatTab === 'scheduled' && briefing.attendanceStatus !== 'scheduled') return false;
|
||||
if (activeStatTab === 'attended' && briefing.attendanceStatus !== 'attended') return false;
|
||||
|
||||
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (partnerFilters.length > 0) {
|
||||
@@ -174,8 +173,8 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
if (!attendeeFilters.includes(attendeeId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && briefing.status !== statusFilter) return false;
|
||||
// 상태 필터 (참석 상태 기준)
|
||||
if (statusFilter !== 'all' && briefing.attendanceStatus !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
@@ -431,8 +430,8 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
const renderTableRow = useCallback(
|
||||
(briefing: SiteBriefing, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(briefing.id);
|
||||
// 목업 데이터에서 상태 매핑
|
||||
const displayStatus = briefing.status === 'scheduled' ? 'scheduled' : 'attended';
|
||||
// 참석 상태 표시
|
||||
const displayStatus = briefing.attendanceStatus || 'scheduled';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -490,7 +489,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(briefing: SiteBriefing, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
const displayStatus = briefing.status === 'scheduled' ? 'scheduled' : 'attended';
|
||||
const displayStatus = briefing.attendanceStatus || 'scheduled';
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
|
||||
@@ -1,188 +1,189 @@
|
||||
'use server';
|
||||
|
||||
import type { SiteBriefing, SiteBriefingStats, SiteBriefingFilter, SiteBriefingListResponse } from './types';
|
||||
import type { SiteBriefing, SiteBriefingStats, SiteBriefingFilter, SiteBriefingListResponse, SiteBriefingFormData } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* 주일 기업 - 현장설명회 관리 Server Actions
|
||||
* TODO: 실제 API 연동 시 구현
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 목업 데이터
|
||||
const mockSiteBriefings: SiteBriefing[] = [
|
||||
{
|
||||
id: '1',
|
||||
briefingCode: 'SB-001',
|
||||
title: '강남 오피스텔 신축공사',
|
||||
description: '강남구 삼성동 오피스텔 신축 현장설명회',
|
||||
partnerId: '1',
|
||||
partnerName: '대한건설',
|
||||
briefingDate: '2025-05-12',
|
||||
briefingTime: '14:00',
|
||||
location: '강남구청 대회의실',
|
||||
address: '서울특별시 강남구 학동로 426',
|
||||
status: 'scheduled',
|
||||
bidStatus: 'pending',
|
||||
bidDate: '2025-05-15',
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiSiteBriefing {
|
||||
id: number;
|
||||
briefing_code: string | null;
|
||||
title: string;
|
||||
description: string | null;
|
||||
partner_id: number | null;
|
||||
partner_name: string | null;
|
||||
briefing_date: string;
|
||||
briefing_time: string | null;
|
||||
briefing_type: string | null;
|
||||
location: string | null;
|
||||
address: string | null;
|
||||
status: string | null;
|
||||
bid_status: string | null;
|
||||
bid_date: string | null;
|
||||
attendees: Array<{ id: string; name: string }> | null; // 백엔드는 attendees (복수형), array 타입
|
||||
attendance_status: string | null;
|
||||
project_name: string | null;
|
||||
site_count: number | null;
|
||||
construction_start_date: string | null;
|
||||
construction_end_date: string | null;
|
||||
vat_type: string | null;
|
||||
work_report: string | null;
|
||||
attendee_count: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by: string | null;
|
||||
}
|
||||
|
||||
interface ApiSiteBriefingStats {
|
||||
total: number;
|
||||
scheduled: number;
|
||||
ongoing: number;
|
||||
completed: number;
|
||||
cancelled: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → SiteBriefing 타입 변환
|
||||
*/
|
||||
function transformSiteBriefing(apiData: ApiSiteBriefing): SiteBriefing {
|
||||
// attendees를 JSON 문자열로 변환 (types.ts의 parseAttendeeItems에서 파싱)
|
||||
const attendeeJson = apiData.attendees ? JSON.stringify(apiData.attendees) : '';
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
briefingCode: apiData.briefing_code || '',
|
||||
title: apiData.title || apiData.project_name || '',
|
||||
description: apiData.description || '',
|
||||
partnerId: apiData.partner_id ? String(apiData.partner_id) : '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
briefingDate: apiData.briefing_date || '',
|
||||
briefingTime: apiData.briefing_time || '',
|
||||
location: apiData.location || '',
|
||||
address: apiData.address || '',
|
||||
status: (apiData.status as SiteBriefing['status']) || 'scheduled',
|
||||
bidStatus: (apiData.bid_status as SiteBriefing['bidStatus']) || 'pending',
|
||||
bidDate: apiData.bid_date,
|
||||
attendee: attendeeJson, // JSON 문자열로 저장 (parseAttendeeItems에서 파싱)
|
||||
attendees: [],
|
||||
attendeeCount: 5,
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: '홍길동',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
briefingCode: 'SB-002',
|
||||
title: '서초 아파트 리모델링',
|
||||
description: '서초구 반포동 아파트 리모델링 현장설명회',
|
||||
partnerId: '2',
|
||||
partnerName: '삼성시공',
|
||||
briefingDate: '2025-05-12',
|
||||
briefingTime: '10:00',
|
||||
location: '서초구청 소회의실',
|
||||
address: '서울특별시 서초구 남부순환로 2584',
|
||||
status: 'ongoing',
|
||||
bidStatus: 'bidding',
|
||||
bidDate: '2025-05-18',
|
||||
attendees: [],
|
||||
attendeeCount: 8,
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: '김철수',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
briefingCode: 'SB-003',
|
||||
title: '여의도 상업시설 신축',
|
||||
description: '영등포구 여의도동 상업시설 신축 현장설명회',
|
||||
partnerId: '3',
|
||||
partnerName: 'LG건설',
|
||||
briefingDate: '2025-05-13',
|
||||
briefingTime: '15:00',
|
||||
location: 'LG트윈타워 회의실',
|
||||
address: '서울특별시 영등포구 여의대로 128',
|
||||
status: 'completed',
|
||||
bidStatus: 'awarded',
|
||||
bidDate: '2025-05-20',
|
||||
attendees: [],
|
||||
attendeeCount: 12,
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: '박영수',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
briefingCode: 'SB-004',
|
||||
title: '송파 주상복합 공사',
|
||||
description: '송파구 잠실동 주상복합 건축 현장설명회',
|
||||
partnerId: '1',
|
||||
partnerName: '대한건설',
|
||||
briefingDate: '2025-05-14',
|
||||
briefingTime: '11:00',
|
||||
location: '롯데월드타워 회의실',
|
||||
address: '서울특별시 송파구 올림픽로 300',
|
||||
status: 'cancelled',
|
||||
bidStatus: 'failed',
|
||||
bidDate: null,
|
||||
attendees: [],
|
||||
attendeeCount: 0,
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: '최민수',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
briefingCode: 'SB-005',
|
||||
title: '마포 물류센터 증축',
|
||||
description: '마포구 상암동 물류센터 증축 현장설명회',
|
||||
partnerId: '2',
|
||||
partnerName: '삼성시공',
|
||||
briefingDate: '2025-05-15',
|
||||
briefingTime: '09:00',
|
||||
location: '상암 DMC 회의실',
|
||||
address: '서울특별시 마포구 상암산로 76',
|
||||
status: 'postponed',
|
||||
bidStatus: 'pending',
|
||||
bidDate: null,
|
||||
attendees: [],
|
||||
attendeeCount: 3,
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: '이영희',
|
||||
},
|
||||
];
|
||||
attendeeCount: apiData.attendee_count || 0,
|
||||
attendanceStatus: (apiData.attendance_status as SiteBriefing['attendanceStatus']) || 'scheduled',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
createdBy: apiData.created_by || '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* SiteBriefingFormData → API 요청 데이터 변환
|
||||
*/
|
||||
function transformFormDataToApi(data: SiteBriefingFormData): Record<string, unknown> {
|
||||
// attendeeItems 배열을 백엔드 형식으로 변환
|
||||
// - id가 있으면 internal (직원), 없으면 external (외부인/직접입력)
|
||||
const attendees = data.attendeeItems && data.attendeeItems.length > 0
|
||||
? data.attendeeItems.map(item => ({
|
||||
...item,
|
||||
type: item.id ? 'internal' : 'external',
|
||||
}))
|
||||
: null;
|
||||
|
||||
return {
|
||||
briefing_code: data.briefingCode,
|
||||
title: data.projectName,
|
||||
description: data.workReport,
|
||||
partner_id: data.partnerId ? Number(data.partnerId) : null,
|
||||
partner_name: data.partnerName,
|
||||
briefing_date: data.briefingDate,
|
||||
briefing_time: data.briefingTime,
|
||||
briefing_type: data.briefingType,
|
||||
location: data.location,
|
||||
attendees: attendees, // 백엔드 필드명: attendees (복수형, array)
|
||||
attendance_status: data.attendanceStatus,
|
||||
project_name: data.projectName,
|
||||
bid_date: data.bidDate,
|
||||
site_count: data.siteCount,
|
||||
construction_start_date: data.constructionStartDate,
|
||||
construction_end_date: data.constructionEndDate,
|
||||
vat_type: data.vatType,
|
||||
work_report: data.workReport,
|
||||
};
|
||||
}
|
||||
|
||||
// 현장설명회 목록 조회
|
||||
export async function getSiteBriefingList(
|
||||
filter?: SiteBriefingFilter
|
||||
): Promise<{ success: boolean; data?: SiteBriefingListResponse; error?: string }> {
|
||||
try {
|
||||
let filtered = [...mockSiteBriefings];
|
||||
// API 쿼리 파라미터 구성 (모든 값을 문자열로 변환)
|
||||
const params: Record<string, string> = {
|
||||
per_page: String(filter?.size ?? 20),
|
||||
page: String(filter?.page ?? 1),
|
||||
};
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(b) =>
|
||||
b.title.toLowerCase().includes(search) ||
|
||||
b.briefingCode.toLowerCase().includes(search) ||
|
||||
b.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
params.search = filter.search;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filtered = filtered.filter((b) => b.status === filter.status);
|
||||
params.status = filter.status;
|
||||
}
|
||||
|
||||
// 입찰 상태 필터
|
||||
if (filter?.bidStatus && filter.bidStatus !== 'all') {
|
||||
filtered = filtered.filter((b) => b.bidStatus === filter.bidStatus);
|
||||
params.bid_status = filter.bidStatus;
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId) {
|
||||
filtered = filtered.filter((b) => b.partnerId === filter.partnerId);
|
||||
params.partner_id = filter.partnerId;
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filtered = filtered.filter((b) => b.briefingDate >= filter.startDate!);
|
||||
params.start_date = filter.startDate;
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filtered = filtered.filter((b) => b.briefingDate <= filter.endDate!);
|
||||
params.end_date = filter.endDate;
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (filter?.sortBy) {
|
||||
switch (filter.sortBy) {
|
||||
case 'latest':
|
||||
filtered.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
break;
|
||||
case 'oldest':
|
||||
filtered.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'dateAsc':
|
||||
filtered.sort((a, b) => new Date(a.briefingDate).getTime() - new Date(b.briefingDate).getTime());
|
||||
break;
|
||||
case 'dateDesc':
|
||||
filtered.sort((a, b) => new Date(b.briefingDate).getTime() - new Date(a.briefingDate).getTime());
|
||||
break;
|
||||
const sortMapping: Record<string, { sort_by: string; sort_dir: string }> = {
|
||||
latest: { sort_by: 'created_at', sort_dir: 'desc' },
|
||||
oldest: { sort_by: 'created_at', sort_dir: 'asc' },
|
||||
dateAsc: { sort_by: 'briefing_date', sort_dir: 'asc' },
|
||||
dateDesc: { sort_by: 'briefing_date', sort_dir: 'desc' },
|
||||
};
|
||||
const sort = sortMapping[filter.sortBy];
|
||||
if (sort) {
|
||||
params.sort_by = sort.sort_by;
|
||||
params.sort_dir = sort.sort_dir;
|
||||
}
|
||||
}
|
||||
|
||||
const page = filter?.page ?? 1;
|
||||
const size = filter?.size ?? 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filtered.slice(start, start + size);
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: {
|
||||
data: ApiSiteBriefing[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
};
|
||||
}>('/site-briefings', { params });
|
||||
|
||||
const apiData = response.data;
|
||||
const items = apiData.data.map(transformSiteBriefing);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filtered.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filtered.length / size),
|
||||
items,
|
||||
total: apiData.total,
|
||||
page: apiData.current_page,
|
||||
size: apiData.per_page,
|
||||
totalPages: apiData.last_page,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
@@ -196,13 +197,12 @@ export async function getSiteBriefing(
|
||||
id: string
|
||||
): Promise<{ success: boolean; data?: SiteBriefing; error?: string }> {
|
||||
try {
|
||||
const briefing = mockSiteBriefings.find((b) => b.id === id);
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ApiSiteBriefing;
|
||||
}>(`/site-briefings/${id}`);
|
||||
|
||||
if (!briefing) {
|
||||
return { success: false, error: '현장설명회를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: briefing };
|
||||
return { success: true, data: transformSiteBriefing(response.data) };
|
||||
} catch (error) {
|
||||
console.error('getSiteBriefing error:', error);
|
||||
return { success: false, error: '현장설명회 조회에 실패했습니다.' };
|
||||
@@ -212,46 +212,86 @@ export async function getSiteBriefing(
|
||||
// 현장설명회 통계 조회
|
||||
export async function getSiteBriefingStats(): Promise<{ success: boolean; data?: SiteBriefingStats; error?: string }> {
|
||||
try {
|
||||
const total = mockSiteBriefings.length;
|
||||
const scheduled = mockSiteBriefings.filter((b) => b.status === 'scheduled').length;
|
||||
const ongoing = mockSiteBriefings.filter((b) => b.status === 'ongoing').length;
|
||||
const completed = mockSiteBriefings.filter((b) => b.status === 'completed').length;
|
||||
const cancelled = mockSiteBriefings.filter((b) => b.status === 'cancelled' || b.status === 'postponed').length;
|
||||
const response = await apiClient.get<{
|
||||
success: boolean;
|
||||
data: ApiSiteBriefingStats;
|
||||
}>('/site-briefings/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
scheduled,
|
||||
ongoing,
|
||||
completed,
|
||||
cancelled,
|
||||
},
|
||||
};
|
||||
return { success: true, data: response.data };
|
||||
} catch (error) {
|
||||
console.error('getSiteBriefingStats error:', error);
|
||||
return { success: false, error: '통계 조회에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장설명회 삭제
|
||||
// ========================================
|
||||
// API 함수 (CRUD)
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* 현장설명회 등록
|
||||
* POST /api/v1/site-briefings
|
||||
*/
|
||||
export async function createSiteBriefing(data: SiteBriefingFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: SiteBriefing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformFormDataToApi(data);
|
||||
const response = await apiClient.post<{ success: boolean; data: ApiSiteBriefing }>('/site-briefings', apiData);
|
||||
return { success: true, data: transformSiteBriefing(response.data) };
|
||||
} catch (error) {
|
||||
console.error('현장설명회 등록 오류:', error);
|
||||
return { success: false, error: '현장설명회 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현장설명회 수정
|
||||
* PUT /api/v1/site-briefings/{id}
|
||||
*/
|
||||
export async function updateSiteBriefing(id: string, data: SiteBriefingFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: SiteBriefing;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = transformFormDataToApi(data);
|
||||
const response = await apiClient.put<{ success: boolean; data: ApiSiteBriefing }>(`/site-briefings/${id}`, apiData);
|
||||
return { success: true, data: transformSiteBriefing(response.data) };
|
||||
} catch (error) {
|
||||
console.error('현장설명회 수정 오류:', error);
|
||||
return { success: false, error: '현장설명회 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현장설명회 삭제
|
||||
* DELETE /api/v1/site-briefings/{id}
|
||||
*/
|
||||
export async function deleteSiteBriefing(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
console.log('Delete site briefing:', id);
|
||||
await apiClient.delete(`/site-briefings/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteSiteBriefing error:', error);
|
||||
console.error('현장설명회 삭제 오류:', error);
|
||||
return { success: false, error: '현장설명회 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장설명회 일괄 삭제
|
||||
/**
|
||||
* 현장설명회 일괄 삭제
|
||||
* DELETE /api/v1/site-briefings/bulk
|
||||
*/
|
||||
export async function deleteSiteBriefings(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
try {
|
||||
console.log('Delete site briefings:', ids);
|
||||
await apiClient.delete('/site-briefings/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteSiteBriefings error:', error);
|
||||
console.error('현장설명회 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export type SiteBriefingStatus = 'scheduled' | 'ongoing' | 'completed' | 'cancel
|
||||
// 입찰 상태
|
||||
export type BidStatus = 'pending' | 'bidding' | 'closed' | 'failed' | 'awarded';
|
||||
|
||||
// 참석자 타입
|
||||
// 참석자 타입 (외부 참석자 - 상세 정보)
|
||||
export interface Attendee {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -18,6 +18,12 @@ export interface Attendee {
|
||||
isAttended: boolean;
|
||||
}
|
||||
|
||||
// 참석자 항목 타입 (내부 직원 또는 직접 입력)
|
||||
export interface AttendeeItem {
|
||||
id: string; // 직원 ID (직접 입력 시 빈 문자열)
|
||||
name: string; // 이름
|
||||
}
|
||||
|
||||
// 현장설명회 타입
|
||||
export interface SiteBriefing {
|
||||
id: string;
|
||||
@@ -41,8 +47,10 @@ export interface SiteBriefing {
|
||||
bidDate: string | null; // 입찰 날짜
|
||||
|
||||
// 참석자 정보
|
||||
attendee: string; // 참석자
|
||||
attendees: Attendee[];
|
||||
attendeeCount: number; // 참석자 수
|
||||
attendanceStatus: AttendanceStatus; // 참석 상태
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
@@ -173,7 +181,7 @@ export interface SiteBriefingFormData {
|
||||
briefingTime: string; // 현장설명회 시간
|
||||
briefingType: BriefingType; // 구분 (온라인/오프라인)
|
||||
location: string; // 현장설명회 장소
|
||||
attendee: string; // 참석자
|
||||
attendeeItems: AttendeeItem[]; // 참석자 목록 (JSON으로 저장)
|
||||
attendanceStatus: AttendanceStatus; // 상태
|
||||
|
||||
// 입찰 정보
|
||||
@@ -219,7 +227,7 @@ export function getEmptySiteBriefingFormData(): SiteBriefingFormData {
|
||||
briefingTime: '',
|
||||
briefingType: 'offline',
|
||||
location: '',
|
||||
attendee: '',
|
||||
attendeeItems: [],
|
||||
attendanceStatus: 'scheduled',
|
||||
projectName: '',
|
||||
bidDate: '',
|
||||
@@ -233,6 +241,26 @@ export function getEmptySiteBriefingFormData(): SiteBriefingFormData {
|
||||
};
|
||||
}
|
||||
|
||||
// attendee JSON 문자열을 AttendeeItem[]로 파싱
|
||||
export function parseAttendeeItems(attendeeJson: string | null): AttendeeItem[] {
|
||||
if (!attendeeJson) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(attendeeJson);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.filter((item): item is AttendeeItem =>
|
||||
typeof item === 'object' && item !== null && typeof item.name === 'string'
|
||||
);
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
// JSON 파싱 실패 시 기존 단일 문자열을 AttendeeItem으로 변환
|
||||
if (attendeeJson.trim()) {
|
||||
return [{ id: '', name: attendeeJson.trim() }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// SiteBriefing을 FormData로 변환
|
||||
export function siteBriefingToFormData(briefing: SiteBriefing): SiteBriefingFormData {
|
||||
return {
|
||||
@@ -243,8 +271,8 @@ export function siteBriefingToFormData(briefing: SiteBriefing): SiteBriefingForm
|
||||
briefingTime: briefing.briefingTime,
|
||||
briefingType: 'offline', // 기본값
|
||||
location: briefing.location,
|
||||
attendee: '', // 기본값
|
||||
attendanceStatus: 'scheduled', // 기본값
|
||||
attendeeItems: parseAttendeeItems(briefing.attendee),
|
||||
attendanceStatus: briefing.attendanceStatus || 'scheduled',
|
||||
projectName: briefing.title,
|
||||
bidDate: briefing.bidDate || '',
|
||||
siteCount: 0, // 기본값
|
||||
|
||||
@@ -1,195 +1,249 @@
|
||||
'use server';
|
||||
|
||||
import type { Site, SiteStats } from './types';
|
||||
import type { Site, SiteStats, SiteStatus } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목업 현장 데이터
|
||||
const MOCK_SITES: Site[] = [
|
||||
{
|
||||
id: '1',
|
||||
siteCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '-',
|
||||
status: 'unregistered',
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
updatedAt: '2025-09-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
siteCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'suspended',
|
||||
createdAt: '2025-09-02T00:00:00Z',
|
||||
updatedAt: '2025-09-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
siteCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'active',
|
||||
createdAt: '2025-09-03T00:00:00Z',
|
||||
updatedAt: '2025-09-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
siteCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'active',
|
||||
createdAt: '2025-09-04T00:00:00Z',
|
||||
updatedAt: '2025-09-04T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
siteCode: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'active',
|
||||
createdAt: '2025-09-05T00:00:00Z',
|
||||
updatedAt: '2025-09-05T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
siteCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'active',
|
||||
createdAt: '2025-09-06T00:00:00Z',
|
||||
updatedAt: '2025-09-06T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
siteCode: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '회사명',
|
||||
siteName: '현장명',
|
||||
address: '서울시 강남구 대현빌라 123길',
|
||||
status: 'pending',
|
||||
createdAt: '2025-09-07T00:00:00Z',
|
||||
updatedAt: '2025-09-07T00:00:00Z',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 주일 기업 - 현장관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiSite {
|
||||
id: number;
|
||||
site_code: string | null;
|
||||
client_id: number | null;
|
||||
name: string;
|
||||
address: string | null;
|
||||
status: SiteStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
client?: {
|
||||
id: number;
|
||||
name: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
interface ApiSiteStats {
|
||||
total: number;
|
||||
construction: number;
|
||||
unregistered: number;
|
||||
suspended: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → Site 타입 변환
|
||||
*/
|
||||
function transformSite(apiData: ApiSite): Site {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
siteCode: apiData.site_code || '',
|
||||
partnerId: apiData.client_id ? String(apiData.client_id) : '',
|
||||
partnerName: apiData.client?.name || '',
|
||||
siteName: apiData.name || '',
|
||||
address: apiData.address || '',
|
||||
status: apiData.status || 'unregistered',
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
interface GetSiteListParams {
|
||||
size?: number;
|
||||
page?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
clientId?: string;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
interface GetSiteListResult {
|
||||
/**
|
||||
* 현장 목록 조회
|
||||
* GET /api/v1/sites
|
||||
*/
|
||||
export async function getSiteList(params: GetSiteListParams = {}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
items: Site[];
|
||||
totalCount: number;
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// 현장 목록 조회
|
||||
export async function getSiteList(params: GetSiteListParams = {}): Promise<GetSiteListResult> {
|
||||
}> {
|
||||
try {
|
||||
// TODO: API 연동 시 실제 API 호출로 변경
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
let filteredSites = [...MOCK_SITES];
|
||||
// 검색
|
||||
if (params.search) queryParams.search = params.search;
|
||||
|
||||
// 날짜 필터
|
||||
if (params.startDate) {
|
||||
filteredSites = filteredSites.filter(
|
||||
(site) => new Date(site.createdAt) >= new Date(params.startDate!)
|
||||
);
|
||||
}
|
||||
if (params.endDate) {
|
||||
filteredSites = filteredSites.filter(
|
||||
(site) => new Date(site.createdAt) <= new Date(params.endDate!)
|
||||
);
|
||||
// 필터
|
||||
if (params.status && params.status !== 'all') queryParams.status = params.status;
|
||||
if (params.clientId && params.clientId !== 'all') queryParams.client_id = params.clientId;
|
||||
|
||||
// 날짜 범위
|
||||
if (params.startDate) queryParams.start_date = params.startDate;
|
||||
if (params.endDate) queryParams.end_date = params.endDate;
|
||||
|
||||
// 페이지네이션
|
||||
if (params.page) queryParams.page = String(params.page);
|
||||
if (params.size) queryParams.per_page = String(params.size);
|
||||
|
||||
// 정렬
|
||||
if (params.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
latest: { field: 'created_at', dir: 'desc' },
|
||||
oldest: { field: 'created_at', dir: 'asc' },
|
||||
partnerNameAsc: { field: 'client_id', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'client_id', dir: 'desc' },
|
||||
siteNameAsc: { field: 'name', dir: 'asc' },
|
||||
siteNameDesc: { field: 'name', dir: 'desc' },
|
||||
};
|
||||
const sort = sortMap[params.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiSite[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/sites', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformSite);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: filteredSites,
|
||||
totalCount: filteredSites.length,
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getSiteList error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '현장 목록을 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('현장 목록 조회 오류:', error);
|
||||
return { success: false, error: '현장 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장 통계 조회
|
||||
export async function getSiteStats(): Promise<{ success: boolean; data?: SiteStats; error?: string }> {
|
||||
/**
|
||||
* 현장 통계 조회
|
||||
* GET /api/v1/sites/stats
|
||||
*/
|
||||
export async function getSiteStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: SiteStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: API 연동 시 실제 API 호출로 변경
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const total = MOCK_SITES.length;
|
||||
const construction = MOCK_SITES.filter((s) => s.status === 'active').length;
|
||||
const unregistered = MOCK_SITES.filter((s) => s.status === 'unregistered').length;
|
||||
const response = await apiClient.get<ApiSiteStats>('/sites/stats');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
construction,
|
||||
unregistered,
|
||||
total: response.total || 0,
|
||||
construction: response.construction || 0,
|
||||
unregistered: response.unregistered || 0,
|
||||
suspended: response.suspended || 0,
|
||||
pending: response.pending || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getSiteStats error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '현장 통계를 불러오는데 실패했습니다.',
|
||||
};
|
||||
console.error('현장 통계 조회 오류:', error);
|
||||
return { success: false, error: '현장 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장 삭제
|
||||
export async function deleteSite(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
/**
|
||||
* 현장 삭제
|
||||
* DELETE /api/v1/sites/{id}
|
||||
*/
|
||||
export async function deleteSite(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: API 연동 시 실제 API 호출로 변경
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await apiClient.delete(`/sites/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteSite error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '현장 삭제에 실패했습니다.',
|
||||
};
|
||||
console.error('현장 삭제 오류:', error);
|
||||
return { success: false, error: '현장 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 현장 일괄 삭제
|
||||
export async function deleteSites(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
/**
|
||||
* 현장 일괄 삭제
|
||||
* DELETE /api/v1/sites/bulk
|
||||
*/
|
||||
export async function deleteSites(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// TODO: API 연동 시 실제 API 호출로 변경
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: ids.length,
|
||||
};
|
||||
await apiClient.delete('/sites/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteSites error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '현장 일괄 삭제에 실패했습니다.',
|
||||
};
|
||||
console.error('현장 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '현장 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 현장 생성/수정 타입
|
||||
// ========================================
|
||||
|
||||
export interface CreateSiteData {
|
||||
siteName: string;
|
||||
partnerId: string;
|
||||
address?: string;
|
||||
status?: SiteStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* 현장 등록
|
||||
* POST /api/v1/sites
|
||||
*/
|
||||
export async function createSite(data: CreateSiteData): Promise<{
|
||||
success: boolean;
|
||||
data?: Site;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const apiData = {
|
||||
name: data.siteName,
|
||||
client_id: data.partnerId ? Number(data.partnerId) : null,
|
||||
address: data.address || null,
|
||||
status: data.status || 'unregistered',
|
||||
};
|
||||
|
||||
const response = await apiClient.post<{ success: boolean; data: ApiSite }>('/sites', apiData);
|
||||
return { success: true, data: transformSite(response.data) };
|
||||
} catch (error) {
|
||||
console.error('현장 등록 오류:', error);
|
||||
return { success: false, error: '현장 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -17,8 +17,10 @@ export type SiteStatus = 'unregistered' | 'suspended' | 'active' | 'pending';
|
||||
// 현장 통계
|
||||
export interface SiteStats {
|
||||
total: number; // 전체 현장
|
||||
construction: number; // 시공 현장
|
||||
construction: number; // 시공 현장 (active)
|
||||
unregistered: number; // 미등록 현장
|
||||
suspended: number; // 중지 현장
|
||||
pending: number; // 보류 현장
|
||||
}
|
||||
|
||||
// 상태 옵션
|
||||
|
||||
@@ -1,184 +1,256 @@
|
||||
'use server';
|
||||
|
||||
import type { StructureReview, StructureReviewStats } from './types';
|
||||
import type { StructureReview, StructureReviewStats, StructureReviewStatus } from './types';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_STRUCTURE_REVIEWS: StructureReview[] = [
|
||||
{
|
||||
id: '1',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteId: '1',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: '2025-12-15',
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
siteId: '2',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: null,
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-02T00:00:00Z',
|
||||
updatedAt: '2025-12-02T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '회사명',
|
||||
siteId: '3',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: null,
|
||||
completionDate: null,
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-03T00:00:00Z',
|
||||
updatedAt: '2025-12-03T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '2',
|
||||
partnerName: '회사명',
|
||||
siteId: '4',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-12-04T00:00:00Z',
|
||||
updatedAt: '2025-12-04T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '3',
|
||||
partnerName: '회사명',
|
||||
siteId: '5',
|
||||
siteName: '현장명',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
createdAt: '2025-12-05T00:00:00Z',
|
||||
updatedAt: '2025-12-05T00:00:00Z',
|
||||
},
|
||||
];
|
||||
/**
|
||||
* 구조검토관리 Server Actions
|
||||
* 표준화된 apiClient 사용 버전
|
||||
*/
|
||||
|
||||
// 구조검토 목록 조회
|
||||
export async function getStructureReviewList(params?: {
|
||||
size?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: { items: StructureReview[]; total: number };
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// ========================================
|
||||
// API 응답 타입
|
||||
// ========================================
|
||||
|
||||
interface ApiStructureReview {
|
||||
id: number;
|
||||
review_number: string | null;
|
||||
partner_id: number | null;
|
||||
partner_name: string | null;
|
||||
site_id: number | null;
|
||||
site_name: string | null;
|
||||
request_date: string | null;
|
||||
review_company: string | null;
|
||||
reviewer_name: string | null;
|
||||
review_date: string | null;
|
||||
completion_date: string | null;
|
||||
status: StructureReviewStatus;
|
||||
file_url: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiStructureReviewStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
completed: number;
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 타입 변환 함수
|
||||
// ========================================
|
||||
|
||||
/**
|
||||
* API 응답 → StructureReview 타입 변환
|
||||
*/
|
||||
function transformStructureReview(apiData: ApiStructureReview): StructureReview {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: MOCK_STRUCTURE_REVIEWS,
|
||||
total: MOCK_STRUCTURE_REVIEWS.length,
|
||||
},
|
||||
id: String(apiData.id),
|
||||
reviewNumber: apiData.review_number || '',
|
||||
partnerId: apiData.partner_id ? String(apiData.partner_id) : '',
|
||||
partnerName: apiData.partner_name || '',
|
||||
siteId: apiData.site_id ? String(apiData.site_id) : '',
|
||||
siteName: apiData.site_name || '',
|
||||
requestDate: apiData.request_date || '',
|
||||
reviewCompany: apiData.review_company || '',
|
||||
reviewerName: apiData.reviewer_name || '',
|
||||
reviewDate: apiData.review_date || null,
|
||||
completionDate: apiData.completion_date || null,
|
||||
status: apiData.status || 'pending',
|
||||
fileUrl: apiData.file_url || undefined,
|
||||
createdAt: apiData.created_at || '',
|
||||
updatedAt: apiData.updated_at || '',
|
||||
};
|
||||
}
|
||||
|
||||
// 구조검토 통계 조회
|
||||
/**
|
||||
* StructureReview → API 요청 데이터 변환
|
||||
*/
|
||||
function transformToApiData(data: Partial<StructureReview>): Record<string, unknown> {
|
||||
return {
|
||||
review_number: data.reviewNumber || null,
|
||||
partner_id: data.partnerId ? Number(data.partnerId) : null,
|
||||
partner_name: data.partnerName || null,
|
||||
site_id: data.siteId ? Number(data.siteId) : null,
|
||||
site_name: data.siteName || null,
|
||||
request_date: data.requestDate || null,
|
||||
review_company: data.reviewCompany || null,
|
||||
reviewer_name: data.reviewerName || null,
|
||||
review_date: data.reviewDate || null,
|
||||
completion_date: data.completionDate || null,
|
||||
status: data.status || 'pending',
|
||||
file_url: data.fileUrl || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// API 함수
|
||||
// ========================================
|
||||
|
||||
interface GetStructureReviewListParams {
|
||||
size?: number;
|
||||
page?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
partnerId?: string;
|
||||
siteId?: string;
|
||||
sortBy?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조검토 목록 조회
|
||||
* GET /api/v1/construction/structure-reviews
|
||||
*/
|
||||
export async function getStructureReviewList(params: GetStructureReviewListParams = {}): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
items: StructureReview[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const queryParams: Record<string, string> = {};
|
||||
|
||||
// 검색
|
||||
if (params.search) queryParams.search = params.search;
|
||||
|
||||
// 필터
|
||||
if (params.status && params.status !== 'all') queryParams.status = params.status;
|
||||
if (params.partnerId && params.partnerId !== 'all') queryParams.partner_id = params.partnerId;
|
||||
if (params.siteId && params.siteId !== 'all') queryParams.site_id = params.siteId;
|
||||
|
||||
// 날짜 범위
|
||||
if (params.startDate) queryParams.start_date = params.startDate;
|
||||
if (params.endDate) queryParams.end_date = params.endDate;
|
||||
|
||||
// 페이지네이션
|
||||
if (params.page) queryParams.page = String(params.page);
|
||||
if (params.size) queryParams.per_page = String(params.size);
|
||||
|
||||
// 정렬
|
||||
if (params.sortBy) {
|
||||
const sortMap: Record<string, { field: string; dir: string }> = {
|
||||
latest: { field: 'created_at', dir: 'desc' },
|
||||
oldest: { field: 'created_at', dir: 'asc' },
|
||||
partnerNameAsc: { field: 'partner_name', dir: 'asc' },
|
||||
partnerNameDesc: { field: 'partner_name', dir: 'desc' },
|
||||
siteNameAsc: { field: 'site_name', dir: 'asc' },
|
||||
siteNameDesc: { field: 'site_name', dir: 'desc' },
|
||||
};
|
||||
const sort = sortMap[params.sortBy];
|
||||
if (sort) {
|
||||
queryParams.sort_by = sort.field;
|
||||
queryParams.sort_dir = sort.dir;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await apiClient.get<{
|
||||
data: ApiStructureReview[];
|
||||
current_page: number;
|
||||
per_page: number;
|
||||
total: number;
|
||||
last_page: number;
|
||||
}>('/construction/structure-reviews', { params: queryParams });
|
||||
|
||||
const items = (response.data || []).map(transformStructureReview);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items,
|
||||
total: response.total || 0,
|
||||
page: response.current_page || 1,
|
||||
size: response.per_page || 20,
|
||||
totalPages: response.last_page || 1,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 목록 조회 오류:', error);
|
||||
return { success: false, error: '구조검토 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조검토 통계 조회
|
||||
* GET /api/v1/construction/structure-reviews/stats
|
||||
*/
|
||||
export async function getStructureReviewStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: StructureReviewStats;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
try {
|
||||
const response = await apiClient.get<ApiStructureReviewStats>('/construction/structure-reviews/stats');
|
||||
|
||||
const pending = MOCK_STRUCTURE_REVIEWS.filter((r) => r.status === 'pending').length;
|
||||
const completed = MOCK_STRUCTURE_REVIEWS.filter((r) => r.status === 'completed').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: MOCK_STRUCTURE_REVIEWS.length,
|
||||
pending,
|
||||
completed,
|
||||
},
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: response.total || 0,
|
||||
pending: response.pending || 0,
|
||||
completed: response.completed || 0,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 통계 조회 오류:', error);
|
||||
return { success: false, error: '구조검토 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 구조검토 상세 조회
|
||||
/**
|
||||
* 구조검토 상세 조회
|
||||
* GET /api/v1/construction/structure-reviews/{id}
|
||||
*/
|
||||
export async function getStructureReview(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: StructureReview;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
try {
|
||||
const response = await apiClient.get<ApiStructureReview>(`/construction/structure-reviews/${id}`);
|
||||
|
||||
const review = MOCK_STRUCTURE_REVIEWS.find((r) => r.id === id);
|
||||
|
||||
if (!review) {
|
||||
return {
|
||||
success: true,
|
||||
data: transformStructureReview(response),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 상세 조회 오류:', error);
|
||||
return { success: false, error: '구조검토 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: review };
|
||||
}
|
||||
|
||||
// 구조검토 생성
|
||||
/**
|
||||
* 구조검토 생성
|
||||
* POST /api/v1/construction/structure-reviews
|
||||
*/
|
||||
export async function createStructureReview(data: Partial<StructureReview>): Promise<{
|
||||
success: boolean;
|
||||
data?: StructureReview;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
const apiData = transformToApiData(data);
|
||||
const response = await apiClient.post<ApiStructureReview>('/construction/structure-reviews', apiData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: String(Date.now()),
|
||||
reviewNumber: data.reviewNumber || '',
|
||||
partnerId: data.partnerId || '',
|
||||
partnerName: data.partnerName || '',
|
||||
siteId: data.siteId || '',
|
||||
siteName: data.siteName || '',
|
||||
requestDate: data.requestDate || '',
|
||||
reviewCompany: data.reviewCompany || '',
|
||||
reviewerName: data.reviewerName || '',
|
||||
reviewDate: data.reviewDate || null,
|
||||
completionDate: data.completionDate || null,
|
||||
status: data.status || 'pending',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: transformStructureReview(response),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 생성 오류:', error);
|
||||
return { success: false, error: '구조검토 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 구조검토 수정
|
||||
/**
|
||||
* 구조검토 수정
|
||||
* PUT /api/v1/construction/structure-reviews/{id}
|
||||
*/
|
||||
export async function updateStructureReview(
|
||||
id: string,
|
||||
data: Partial<StructureReview>
|
||||
@@ -187,43 +259,53 @@ export async function updateStructureReview(
|
||||
data?: StructureReview;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
try {
|
||||
const apiData = transformToApiData(data);
|
||||
const response = await apiClient.put<ApiStructureReview>(`/construction/structure-reviews/${id}`, apiData);
|
||||
|
||||
const existing = MOCK_STRUCTURE_REVIEWS.find((r) => r.id === id);
|
||||
if (!existing) {
|
||||
return { success: false, error: '구조검토 정보를 찾을 수 없습니다.' };
|
||||
return {
|
||||
success: true,
|
||||
data: transformStructureReview(response),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('구조검토 수정 오류:', error);
|
||||
return { success: false, error: '구조검토 수정에 실패했습니다.' };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...existing,
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 구조검토 삭제
|
||||
/**
|
||||
* 구조검토 삭제
|
||||
* DELETE /api/v1/construction/structure-reviews/{id}
|
||||
*/
|
||||
export async function deleteStructureReview(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return { success: true };
|
||||
try {
|
||||
await apiClient.delete(`/construction/structure-reviews/${id}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('구조검토 삭제 오류:', error);
|
||||
return { success: false, error: '구조검토 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 구조검토 일괄 삭제
|
||||
/**
|
||||
* 구조검토 일괄 삭제
|
||||
* DELETE /api/v1/construction/structure-reviews/bulk
|
||||
*/
|
||||
export async function deleteStructureReviews(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
// TODO: API 연동
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return { success: true, deletedCount: ids.length };
|
||||
try {
|
||||
await apiClient.delete('/construction/structure-reviews/bulk', {
|
||||
data: { ids: ids.map((id) => Number(id)) },
|
||||
});
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('구조검토 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '구조검토 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
@@ -311,6 +311,32 @@ export function EmployeeForm({
|
||||
}));
|
||||
};
|
||||
|
||||
// 부서 선택 변경 (id와 name 모두 업데이트)
|
||||
const handleDepartmentSelect = (dpId: string, departmentId: string) => {
|
||||
const dept = departments.find(d => String(d.id) === departmentId);
|
||||
if (dept) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: prev.departmentPositions.map(dp =>
|
||||
dp.id === dpId ? { ...dp, departmentId: String(dept.id), departmentName: dept.name } : dp
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 직책 선택 변경 (id와 name 모두 업데이트)
|
||||
const handlePositionSelect = (dpId: string, positionId: string) => {
|
||||
const position = titles.find(t => String(t.id) === positionId);
|
||||
if (position) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
departmentPositions: prev.departmentPositions.map(dp =>
|
||||
dp.id === dpId ? { ...dp, positionId: String(position.id), positionName: position.name } : dp
|
||||
),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// 저장
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -624,13 +650,20 @@ export function EmployeeForm({
|
||||
{fieldSettings.showRank && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="rank">직급</Label>
|
||||
<Input
|
||||
id="rank"
|
||||
<Select
|
||||
value={formData.rank}
|
||||
onChange={(e) => handleChange('rank', e.target.value)}
|
||||
placeholder="직급 입력"
|
||||
onValueChange={(value) => handleChange('rank', value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger disabled={isViewMode}>
|
||||
<SelectValue placeholder="직급 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ranks.map((rank) => (
|
||||
<SelectItem key={rank.id} value={rank.name}>{rank.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -681,20 +714,38 @@ export function EmployeeForm({
|
||||
<div className="space-y-2">
|
||||
{formData.departmentPositions.map((dp) => (
|
||||
<div key={dp.id} className="flex items-center gap-2">
|
||||
<Input
|
||||
value={dp.departmentName}
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'departmentName', e.target.value)}
|
||||
placeholder="부서명"
|
||||
className="flex-1"
|
||||
<Select
|
||||
value={dp.departmentId}
|
||||
onValueChange={(value) => handleDepartmentSelect(dp.id, value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Input
|
||||
value={dp.positionName}
|
||||
onChange={(e) => handleDepartmentPositionChange(dp.id, 'positionName', e.target.value)}
|
||||
placeholder="직책"
|
||||
className="flex-1"
|
||||
>
|
||||
<SelectTrigger className="flex-1" disabled={isViewMode}>
|
||||
<SelectValue placeholder="부서 선택">
|
||||
{dp.departmentName || '부서 선택'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{departments.map((dept) => (
|
||||
<SelectItem key={dept.id} value={String(dept.id)}>{dept.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={dp.positionId}
|
||||
onValueChange={(value) => handlePositionSelect(dp.id, value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="flex-1" disabled={isViewMode}>
|
||||
<SelectValue placeholder="직책 선택">
|
||||
{dp.positionName || '직책 선택'}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{titles.map((title) => (
|
||||
<SelectItem key={title.id} value={String(title.id)}>{title.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useDaumPostcode } from "@/hooks/useDaumPostcode";
|
||||
import { useClientList } from "@/hooks/useClientList";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -49,7 +50,8 @@ import {
|
||||
ResponsiveFormTemplate,
|
||||
FormSection,
|
||||
} from "@/components/templates/ResponsiveFormTemplate";
|
||||
import { QuotationSelectDialog, QuotationForSelect, QuotationItem } from "./QuotationSelectDialog";
|
||||
import { QuotationSelectDialog } from "./QuotationSelectDialog";
|
||||
import { type QuotationForSelect, type QuotationItem } from "./actions";
|
||||
import { ItemAddDialog, OrderItem } from "./ItemAddDialog";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -133,15 +135,6 @@ const SHIPPING_COSTS = [
|
||||
{ value: "negotiable", label: "협의" },
|
||||
];
|
||||
|
||||
// 샘플 발주처 데이터
|
||||
const SAMPLE_CLIENTS = [
|
||||
{ id: "C001", name: "태영건설(주)" },
|
||||
{ id: "C002", name: "현대건설(주)" },
|
||||
{ id: "C003", name: "GS건설(주)" },
|
||||
{ id: "C004", name: "대우건설(주)" },
|
||||
{ id: "C005", name: "포스코건설" },
|
||||
];
|
||||
|
||||
interface OrderRegistrationProps {
|
||||
onBack: () => void;
|
||||
onSave: (formData: OrderFormData) => Promise<void>;
|
||||
@@ -184,6 +177,14 @@ export function OrderRegistration({
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
|
||||
|
||||
// 거래처 목록 조회
|
||||
const { clients, fetchClients, isLoading: isClientsLoading } = useClientList();
|
||||
|
||||
// 컴포넌트 마운트 시 거래처 목록 불러오기
|
||||
useEffect(() => {
|
||||
fetchClients({ onlyActive: true, size: 100 });
|
||||
}, [fetchClients]);
|
||||
|
||||
// Daum 우편번호 서비스
|
||||
const { openPostcode } = useDaumPostcode({
|
||||
onComplete: (result) => {
|
||||
@@ -230,6 +231,7 @@ export function OrderRegistration({
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
selectedQuotation: quotation,
|
||||
clientId: quotation.clientId || "", // 견적의 발주처 ID 설정
|
||||
clientName: quotation.client,
|
||||
siteName: quotation.siteName,
|
||||
manager: quotation.manager || "",
|
||||
@@ -237,6 +239,8 @@ export function OrderRegistration({
|
||||
items,
|
||||
}));
|
||||
|
||||
// 발주처 에러 초기화
|
||||
clearFieldError("clientName");
|
||||
toast.success("견적 정보가 불러와졌습니다.");
|
||||
};
|
||||
|
||||
@@ -412,7 +416,7 @@ export function OrderRegistration({
|
||||
<span>{form.selectedQuotation.siteName}</span>
|
||||
<span className="text-muted-foreground mx-2">/</span>
|
||||
<span className="text-green-600 font-medium">
|
||||
{formatAmount(form.selectedQuotation.amount)}원
|
||||
{formatAmount(form.selectedQuotation.amount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -448,7 +452,7 @@ export function OrderRegistration({
|
||||
<Select
|
||||
value={form.clientId}
|
||||
onValueChange={(value) => {
|
||||
const client = SAMPLE_CLIENTS.find((c) => c.id === value);
|
||||
const client = clients.find((c) => c.id === value);
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
clientId: value,
|
||||
@@ -456,14 +460,15 @@ export function OrderRegistration({
|
||||
}));
|
||||
clearFieldError("clientName");
|
||||
}}
|
||||
disabled={!!form.selectedQuotation || isClientsLoading}
|
||||
>
|
||||
<SelectTrigger className={cn(fieldErrors.clientName && "border-red-500")}>
|
||||
<SelectValue placeholder="발주처 선택">
|
||||
{form.clientName || "발주처 선택"}
|
||||
<SelectValue placeholder={isClientsLoading ? "불러오는 중..." : "발주처 선택"}>
|
||||
{form.clientName || (isClientsLoading ? "불러오는 중..." : "발주처 선택")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SAMPLE_CLIENTS.map((client) => (
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
@@ -486,6 +491,7 @@ export function OrderRegistration({
|
||||
setForm((prev) => ({ ...prev, siteName: e.target.value }));
|
||||
clearFieldError("siteName");
|
||||
}}
|
||||
disabled={!!form.selectedQuotation}
|
||||
className={cn(fieldErrors.siteName && "border-red-500")}
|
||||
/>
|
||||
{fieldErrors.siteName && (
|
||||
@@ -752,8 +758,6 @@ export function OrderRegistration({
|
||||
<TableHead className="w-[60px] text-center">순번</TableHead>
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품명</TableHead>
|
||||
<TableHead>층</TableHead>
|
||||
<TableHead>부호</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="w-[80px] text-center">수량</TableHead>
|
||||
<TableHead className="w-[60px] text-center">단위</TableHead>
|
||||
@@ -765,7 +769,7 @@ export function OrderRegistration({
|
||||
<TableBody>
|
||||
{form.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={11} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
품목이 없습니다. 견적을 선택하거나 품목을 추가해주세요.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -779,8 +783,6 @@ export function OrderRegistration({
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell>{item.type || "-"}</TableCell>
|
||||
<TableCell>{item.symbol || "-"}</TableCell>
|
||||
<TableCell>{item.spec}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Input
|
||||
@@ -798,10 +800,10 @@ export function OrderRegistration({
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.unitPrice)}원
|
||||
{formatAmount(item.unitPrice)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.amount)}원
|
||||
{formatAmount(item.amount)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
@@ -833,7 +835,7 @@ export function OrderRegistration({
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-muted-foreground">소계:</span>
|
||||
<span className="w-32 text-right">
|
||||
{formatAmount(form.subtotal)}원
|
||||
{formatAmount(form.subtotal)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
@@ -855,7 +857,7 @@ export function OrderRegistration({
|
||||
<div className="flex items-center gap-4 text-lg font-semibold">
|
||||
<span>총금액:</span>
|
||||
<span className="w-32 text-right text-green-600">
|
||||
{formatAmount(form.totalAmount)}원
|
||||
{formatAmount(form.totalAmount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* 견적 선택 팝업
|
||||
*
|
||||
* 확정된 견적 목록에서 수주 전환할 견적을 선택하는 다이얼로그
|
||||
* API 연동: getQuotesForSelect (FINALIZED 상태 견적만 조회)
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,37 +16,10 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Search, FileText, Check } from "lucide-react";
|
||||
import { Search, FileText, Check, Loader2 } from "lucide-react";
|
||||
import { formatAmount } from "@/utils/formatAmount";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// 견적 타입
|
||||
export interface QuotationForSelect {
|
||||
id: string;
|
||||
quoteNumber: string; // KD-PR-XXXXXX-XX
|
||||
grade: string; // A(우량), B(관리), C(주의)
|
||||
client: string; // 발주처
|
||||
siteName: string; // 현장명
|
||||
amount: number; // 총 금액
|
||||
itemCount: number; // 품목 수
|
||||
registrationDate: string; // 견적일
|
||||
manager?: string; // 담당자
|
||||
contact?: string; // 연락처
|
||||
items?: QuotationItem[]; // 품목 내역
|
||||
}
|
||||
|
||||
export interface QuotationItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
type: string; // 종
|
||||
symbol: string; // 부호
|
||||
spec: string; // 규격
|
||||
quantity: number;
|
||||
unit: string;
|
||||
unitPrice: number;
|
||||
amount: number;
|
||||
}
|
||||
import { getQuotesForSelect, type QuotationForSelect } from "./actions";
|
||||
|
||||
interface QuotationSelectDialogProps {
|
||||
open: boolean;
|
||||
@@ -54,81 +28,6 @@ interface QuotationSelectDialogProps {
|
||||
selectedId?: string;
|
||||
}
|
||||
|
||||
// 샘플 견적 데이터 (실제 구현에서는 API 연동)
|
||||
const SAMPLE_QUOTATIONS: QuotationForSelect[] = [
|
||||
{
|
||||
id: "QT-001",
|
||||
quoteNumber: "KD-PR-251210-01",
|
||||
grade: "A",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
amount: 38800000,
|
||||
itemCount: 5,
|
||||
registrationDate: "2024-12-10",
|
||||
manager: "김철수",
|
||||
contact: "010-1234-5678",
|
||||
items: [
|
||||
{ id: "1", itemCode: "PRD-001", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS1", spec: "7260×2600", quantity: 2, unit: "EA", unitPrice: 8000000, amount: 16000000 },
|
||||
{ id: "2", itemCode: "PRD-002", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "5000×2400", quantity: 3, unit: "EA", unitPrice: 7600000, amount: 22800000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "QT-002",
|
||||
quoteNumber: "KD-PR-251211-02",
|
||||
grade: "A",
|
||||
client: "현대건설(주)",
|
||||
siteName: "힐스테이트 판교역",
|
||||
amount: 52500000,
|
||||
itemCount: 8,
|
||||
registrationDate: "2024-12-11",
|
||||
manager: "이영희",
|
||||
contact: "010-2345-6789",
|
||||
items: [
|
||||
{ id: "1", itemCode: "PRD-003", itemName: "국민방화스크린세터", type: "B2", symbol: "FSS1", spec: "6000×3000", quantity: 4, unit: "EA", unitPrice: 9500000, amount: 38000000 },
|
||||
{ id: "2", itemCode: "PRD-004", itemName: "국민방화스크린세터", type: "B1", symbol: "FSS2", spec: "4500×2500", quantity: 2, unit: "EA", unitPrice: 7250000, amount: 14500000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "QT-003",
|
||||
quoteNumber: "KD-PR-251208-03",
|
||||
grade: "B",
|
||||
client: "GS건설(주)",
|
||||
siteName: "자이 강남센터",
|
||||
amount: 45000000,
|
||||
itemCount: 6,
|
||||
registrationDate: "2024-12-08",
|
||||
manager: "박민수",
|
||||
contact: "010-3456-7890",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: "QT-004",
|
||||
quoteNumber: "KD-PR-251205-04",
|
||||
grade: "B",
|
||||
client: "대우건설(주)",
|
||||
siteName: "푸르지오 송도",
|
||||
amount: 28900000,
|
||||
itemCount: 4,
|
||||
registrationDate: "2024-12-05",
|
||||
manager: "최지원",
|
||||
contact: "010-4567-8901",
|
||||
items: [],
|
||||
},
|
||||
{
|
||||
id: "QT-005",
|
||||
quoteNumber: "KD-PR-251201-05",
|
||||
grade: "A",
|
||||
client: "포스코건설",
|
||||
siteName: "더샵 분당센트럴",
|
||||
amount: 62000000,
|
||||
itemCount: 10,
|
||||
registrationDate: "2024-12-01",
|
||||
manager: "정수민",
|
||||
contact: "010-5678-9012",
|
||||
items: [],
|
||||
},
|
||||
];
|
||||
|
||||
// 등급 배지 컴포넌트
|
||||
function GradeBadge({ grade }: { grade: string }) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
@@ -151,25 +50,48 @@ export function QuotationSelectDialog({
|
||||
selectedId,
|
||||
}: QuotationSelectDialogProps) {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [quotations] = useState<QuotationForSelect[]>(SAMPLE_QUOTATIONS);
|
||||
const [quotations, setQuotations] = useState<QuotationForSelect[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 검색 필터링
|
||||
const filteredQuotations = quotations.filter((q) => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return (
|
||||
!searchTerm ||
|
||||
q.quoteNumber.toLowerCase().includes(searchLower) ||
|
||||
q.client.toLowerCase().includes(searchLower) ||
|
||||
q.siteName.toLowerCase().includes(searchLower)
|
||||
);
|
||||
});
|
||||
// 견적 목록 조회
|
||||
const fetchQuotations = useCallback(async (query?: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await getQuotesForSelect({ q: query, size: 50 });
|
||||
if (result.success && result.data) {
|
||||
setQuotations(result.data.items);
|
||||
} else {
|
||||
setError(result.error || "견적 목록 조회에 실패했습니다.");
|
||||
setQuotations([]);
|
||||
}
|
||||
} catch {
|
||||
setError("서버 오류가 발생했습니다.");
|
||||
setQuotations([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 다이얼로그 열릴 때 검색어 초기화
|
||||
// 다이얼로그 열릴 때 데이터 로드
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSearchTerm("");
|
||||
fetchQuotations();
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, fetchQuotations]);
|
||||
|
||||
// 검색어 변경 시 디바운스 적용하여 API 호출
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
fetchQuotations(searchTerm || undefined);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm, open, fetchQuotations]);
|
||||
|
||||
const handleSelect = (quotation: QuotationForSelect) => {
|
||||
onSelect(quotation);
|
||||
@@ -199,60 +121,77 @@ export function QuotationSelectDialog({
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
전환 가능한 견적 {filteredQuotations.length}건 (최종확정 상태)
|
||||
{isLoading ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
견적 목록을 불러오는 중...
|
||||
</span>
|
||||
) : error ? (
|
||||
<span className="text-red-500">{error}</span>
|
||||
) : (
|
||||
`전환 가능한 견적 ${quotations.length}건 (최종확정 상태)`
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 견적 목록 */}
|
||||
<div className="flex-1 overflow-y-auto space-y-3 pr-2">
|
||||
{filteredQuotations.map((quotation) => (
|
||||
<div
|
||||
key={quotation.id}
|
||||
onClick={() => handleSelect(quotation)}
|
||||
className={cn(
|
||||
"p-4 border rounded-lg cursor-pointer transition-colors",
|
||||
"hover:bg-muted/50 hover:border-primary/50",
|
||||
selectedId === quotation.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{/* 상단: 견적번호 + 등급 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
{quotation.quoteNumber}
|
||||
</code>
|
||||
<GradeBadge grade={quotation.grade} />
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{quotations.map((quotation) => (
|
||||
<div
|
||||
key={quotation.id}
|
||||
onClick={() => handleSelect(quotation)}
|
||||
className={cn(
|
||||
"p-4 border rounded-lg cursor-pointer transition-colors",
|
||||
"hover:bg-muted/50 hover:border-primary/50",
|
||||
selectedId === quotation.id && "border-primary bg-primary/5"
|
||||
)}
|
||||
>
|
||||
{/* 상단: 견적번호 + 등급 */}
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
|
||||
{quotation.quoteNumber}
|
||||
</code>
|
||||
<GradeBadge grade={quotation.grade} />
|
||||
</div>
|
||||
{selectedId === quotation.id && (
|
||||
<Check className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 발주처 */}
|
||||
<div className="font-medium text-base mb-1">
|
||||
{quotation.client}
|
||||
</div>
|
||||
|
||||
{/* 현장명 + 금액 */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
[{quotation.siteName}]
|
||||
</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(quotation.amount)}원
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 품목 수 */}
|
||||
<div className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{quotation.itemCount}개 품목
|
||||
</div>
|
||||
</div>
|
||||
{selectedId === quotation.id && (
|
||||
<Check className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 발주처 */}
|
||||
<div className="font-medium text-base mb-1">
|
||||
{quotation.client}
|
||||
</div>
|
||||
|
||||
{/* 현장명 + 금액 */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
[{quotation.siteName}]
|
||||
</span>
|
||||
<span className="font-medium text-green-600">
|
||||
{formatAmount(quotation.amount)}원
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 품목 수 */}
|
||||
<div className="text-xs text-muted-foreground mt-1 text-right">
|
||||
{quotation.itemCount}개 품목
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{filteredQuotations.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
{quotations.length === 0 && !error && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
1080
src/components/orders/actions.ts
Normal file
1080
src/components/orders/actions.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,26 @@
|
||||
/**
|
||||
* 수주 관련 컴포넌트
|
||||
* 수주 관련 컴포넌트 및 API 함수
|
||||
*/
|
||||
|
||||
// API Actions
|
||||
export {
|
||||
getOrders,
|
||||
getOrderById,
|
||||
createOrder,
|
||||
updateOrder,
|
||||
deleteOrder,
|
||||
deleteOrders,
|
||||
updateOrderStatus,
|
||||
getOrderStats,
|
||||
type Order,
|
||||
type OrderItem as OrderItemApi,
|
||||
type OrderFormData as OrderApiFormData,
|
||||
type OrderItemFormData,
|
||||
type OrderStats,
|
||||
type OrderStatus,
|
||||
} from "./actions";
|
||||
|
||||
// Components
|
||||
export { OrderRegistration, type OrderFormData } from "./OrderRegistration";
|
||||
export { QuotationSelectDialog, type QuotationForSelect, type QuotationItem } from "./QuotationSelectDialog";
|
||||
export { ItemAddDialog, type OrderItem } from "./ItemAddDialog";
|
||||
|
||||
@@ -178,39 +178,36 @@ export function ProcessDetail({ process }: ProcessDetailProps) {
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
개별 품목
|
||||
{individualItems.length > 0 && individualItems[0].items && (
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{individualItems[0].items.length}개
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{individualItems.length === 0 ? (
|
||||
{individualItems.length === 0 || !individualItems[0].items?.length ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Package className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm">등록된 개별 품목이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{individualItems.map((rule) => (
|
||||
<div
|
||||
key={rule.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<Badge variant={rule.isActive ? 'default' : 'secondary'}>
|
||||
{rule.isActive ? '활성' : '비활성'}
|
||||
</Badge>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{rule.conditionValue}
|
||||
</div>
|
||||
{rule.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{rule.description}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
{individualItems[0].items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg bg-muted/30 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
{item.code}
|
||||
</Badge>
|
||||
<span className="font-medium">{item.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">우선순위: {rule.priority}</Badge>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -74,7 +74,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
// 개별 품목용 상태
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [selectedItemType, setSelectedItemType] = useState('all');
|
||||
const [selectedItemCodes, setSelectedItemCodes] = useState<Set<string>>(new Set());
|
||||
const [selectedItemIds, setSelectedItemIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 품목 목록 API 상태
|
||||
const [itemList, setItemList] = useState<ItemOption[]>([]);
|
||||
@@ -86,16 +86,35 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
const items = await getItemList({
|
||||
q: q || undefined,
|
||||
itemType: itemType === 'all' ? undefined : itemType,
|
||||
size: 100,
|
||||
size: 1000, // 전체 품목 조회
|
||||
});
|
||||
setItemList(items);
|
||||
setIsItemsLoading(false);
|
||||
}, []);
|
||||
|
||||
// 검색어 유효성 검사 함수
|
||||
const isValidSearchKeyword = (keyword: string): boolean => {
|
||||
if (!keyword || keyword.trim() === '') return false;
|
||||
|
||||
const trimmed = keyword.trim();
|
||||
// 한글이 포함되어 있으면 1자 이상
|
||||
const hasKorean = /[가-힣]/.test(trimmed);
|
||||
if (hasKorean) return trimmed.length >= 1;
|
||||
|
||||
// 영어/숫자만 있으면 2자 이상
|
||||
return trimmed.length >= 2;
|
||||
};
|
||||
|
||||
// 검색어/품목유형 변경 시 API 호출 (debounce)
|
||||
useEffect(() => {
|
||||
if (registrationType !== 'individual') return;
|
||||
|
||||
// 검색어 유효성 검사 - 유효하지 않으면 빈 목록
|
||||
if (!isValidSearchKeyword(searchKeyword)) {
|
||||
setItemList([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
loadItems(searchKeyword, selectedItemType);
|
||||
}, 300);
|
||||
@@ -103,21 +122,30 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchKeyword, selectedItemType, registrationType, loadItems]);
|
||||
|
||||
// 모달 열릴 때 품목 목록 초기 로드
|
||||
// 품목유형 변경 시 검색어가 유효하면 재검색
|
||||
useEffect(() => {
|
||||
if (registrationType !== 'individual') return;
|
||||
if (!isValidSearchKeyword(searchKeyword)) return;
|
||||
|
||||
loadItems(searchKeyword, selectedItemType);
|
||||
}, [selectedItemType]);
|
||||
|
||||
// 모달 열릴 때 품목 목록 초기화 (초기 로드 안함)
|
||||
useEffect(() => {
|
||||
if (open && registrationType === 'individual') {
|
||||
loadItems('', 'all');
|
||||
setItemList([]);
|
||||
setSearchKeyword('');
|
||||
}
|
||||
}, [open, registrationType, loadItems]);
|
||||
}, [open, registrationType]);
|
||||
|
||||
// 체크박스 토글
|
||||
const handleToggleItem = (code: string) => {
|
||||
setSelectedItemCodes((prev) => {
|
||||
const handleToggleItem = (id: string) => {
|
||||
setSelectedItemIds((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(code)) {
|
||||
newSet.delete(code);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(code);
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
@@ -125,13 +153,13 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
|
||||
// 전체 선택
|
||||
const handleSelectAll = () => {
|
||||
const allCodes = itemList.map((item) => item.code);
|
||||
setSelectedItemCodes(new Set(allCodes));
|
||||
const allIds = itemList.map((item) => item.id);
|
||||
setSelectedItemIds(new Set(allIds));
|
||||
};
|
||||
|
||||
// 초기화
|
||||
const handleResetSelection = () => {
|
||||
setSelectedItemCodes(new Set());
|
||||
setSelectedItemIds(new Set());
|
||||
};
|
||||
|
||||
// 모달 열릴 때 초기화 또는 수정 데이터 로드
|
||||
@@ -149,12 +177,12 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
|
||||
// 개별 품목인 경우 선택된 품목 코드 설정
|
||||
// 개별 품목인 경우 선택된 품목 ID 설정
|
||||
if (editRule.registrationType === 'individual') {
|
||||
const codes = editRule.conditionValue.split(',').filter(Boolean);
|
||||
setSelectedItemCodes(new Set(codes));
|
||||
const ids = editRule.conditionValue.split(',').filter(Boolean);
|
||||
setSelectedItemIds(new Set(ids));
|
||||
} else {
|
||||
setSelectedItemCodes(new Set());
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
} else {
|
||||
// 추가 모드: 초기화
|
||||
@@ -167,7 +195,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
setIsActive(true);
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
setSelectedItemCodes(new Set());
|
||||
setSelectedItemIds(new Set());
|
||||
}
|
||||
}
|
||||
}, [open, editRule]);
|
||||
@@ -179,7 +207,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (selectedItemCodes.size === 0) {
|
||||
if (selectedItemIds.size === 0) {
|
||||
alert('품목을 최소 1개 이상 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
@@ -188,7 +216,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
// 개별 품목의 경우 conditionValue에 품목코드들을 저장
|
||||
const finalConditionValue =
|
||||
registrationType === 'individual'
|
||||
? Array.from(selectedItemCodes).join(',')
|
||||
? Array.from(selectedItemIds).join(',')
|
||||
: conditionValue.trim();
|
||||
|
||||
onAdd({
|
||||
@@ -211,7 +239,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
setIsActive(true);
|
||||
setSearchKeyword('');
|
||||
setSelectedItemType('all');
|
||||
setSelectedItemCodes(new Set());
|
||||
setSelectedItemIds(new Set());
|
||||
|
||||
onOpenChange(false);
|
||||
};
|
||||
@@ -389,7 +417,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
{isItemsLoading ? (
|
||||
'로딩 중...'
|
||||
) : (
|
||||
<>품목 목록 ({itemList.length}개) | 선택됨 ({selectedItemCodes.size}개)</>
|
||||
<>품목 목록 ({itemList.length}개) | 선택됨 ({selectedItemIds.size}개)</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -411,7 +439,7 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
size="sm"
|
||||
onClick={handleResetSelection}
|
||||
className="text-xs h-7"
|
||||
disabled={selectedItemCodes.size === 0}
|
||||
disabled={selectedItemIds.size === 0}
|
||||
>
|
||||
초기화
|
||||
</Button>
|
||||
@@ -439,7 +467,9 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
) : itemList.length === 0 ? (
|
||||
<TableRow key="empty">
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground py-8">
|
||||
검색 결과가 없습니다
|
||||
{searchKeyword.trim() === ''
|
||||
? '품목을 검색해주세요 (한글 1자 이상, 영문 2자 이상)'
|
||||
: '검색 결과가 없습니다'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -447,12 +477,12 @@ export function RuleModal({ open, onOpenChange, onAdd, editRule }: RuleModalProp
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleToggleItem(item.code)}
|
||||
onClick={() => handleToggleItem(item.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedItemCodes.has(item.code)}
|
||||
onCheckedChange={() => handleToggleItem(item.code)}
|
||||
checked={selectedItemIds.has(item.id)}
|
||||
onCheckedChange={() => handleToggleItem(item.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { serverFetch } from '@/lib/api/fetch-wrapper';
|
||||
import type { Process, ProcessFormData, ClassificationRule } from '@/types/process';
|
||||
import type { Process, ProcessFormData, ClassificationRule, IndividualItem } from '@/types/process';
|
||||
|
||||
// ============================================================================
|
||||
// API 타입 정의
|
||||
@@ -26,6 +26,7 @@ interface ApiProcess {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
classification_rules?: ApiClassificationRule[];
|
||||
process_items?: ApiProcessItem[];
|
||||
}
|
||||
|
||||
interface ApiClassificationRule {
|
||||
@@ -42,6 +43,19 @@ interface ApiClassificationRule {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface ApiProcessItem {
|
||||
id: number;
|
||||
process_id: number;
|
||||
item_id: number;
|
||||
priority: number;
|
||||
is_active: boolean;
|
||||
item?: {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
message: string;
|
||||
@@ -61,6 +75,12 @@ interface PaginatedResponse<T> {
|
||||
// ============================================================================
|
||||
|
||||
function transformApiToFrontend(apiData: ApiProcess): Process {
|
||||
// Pattern 규칙 변환
|
||||
const patternRules = (apiData.classification_rules ?? []).map(transformRuleApiToFrontend);
|
||||
|
||||
// 개별 품목 → individual 분류 규칙으로 변환
|
||||
const individualRules = transformProcessItemsToRules(apiData.process_items ?? []);
|
||||
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
processCode: apiData.process_code,
|
||||
@@ -69,7 +89,7 @@ function transformApiToFrontend(apiData: ApiProcess): Process {
|
||||
processType: apiData.process_type as Process['processType'],
|
||||
department: apiData.department ?? '',
|
||||
workLogTemplate: apiData.work_log_template ?? undefined,
|
||||
classificationRules: (apiData.classification_rules ?? []).map(transformRuleApiToFrontend),
|
||||
classificationRules: [...patternRules, ...individualRules],
|
||||
requiredWorkers: apiData.required_workers,
|
||||
equipmentInfo: apiData.equipment_info ?? undefined,
|
||||
workSteps: apiData.work_steps ?? [],
|
||||
@@ -80,6 +100,44 @@ function transformApiToFrontend(apiData: ApiProcess): Process {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* process_items 배열을 individual 분류 규칙으로 변환
|
||||
* 모든 개별 품목을 하나의 규칙으로 통합
|
||||
*/
|
||||
function transformProcessItemsToRules(processItems: ApiProcessItem[]): ClassificationRule[] {
|
||||
if (processItems.length === 0) return [];
|
||||
|
||||
const activeItems = processItems.filter(pi => pi.is_active);
|
||||
if (activeItems.length === 0) return [];
|
||||
|
||||
// 모든 품목 ID를 쉼표로 구분하여 하나의 규칙으로 통합
|
||||
const itemIds = activeItems
|
||||
.map(pi => String(pi.item_id))
|
||||
.join(',');
|
||||
|
||||
// 품목 상세 정보 추출 (code, name 포함)
|
||||
const items: IndividualItem[] = activeItems
|
||||
.filter(pi => pi.item) // item 정보가 있는 것만
|
||||
.map(pi => ({
|
||||
id: String(pi.item!.id),
|
||||
code: pi.item!.code,
|
||||
name: pi.item!.name,
|
||||
}));
|
||||
|
||||
return [{
|
||||
id: `individual-${Date.now()}`,
|
||||
registrationType: 'individual',
|
||||
ruleType: '품목코드',
|
||||
matchingType: 'equals',
|
||||
conditionValue: itemIds,
|
||||
priority: 0,
|
||||
description: `개별 품목 ${activeItems.length}개`,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
items, // 품목 상세 정보 추가
|
||||
}];
|
||||
}
|
||||
|
||||
function transformRuleApiToFrontend(apiRule: ApiClassificationRule): ClassificationRule {
|
||||
return {
|
||||
id: String(apiRule.id),
|
||||
@@ -95,6 +153,24 @@ function transformRuleApiToFrontend(apiRule: ApiClassificationRule): Classificat
|
||||
}
|
||||
|
||||
function transformFrontendToApi(data: ProcessFormData): Record<string, unknown> {
|
||||
// 패턴 규칙만 분리 (individual 제외)
|
||||
const patternRules = data.classificationRules.filter(
|
||||
(rule) => rule.registrationType === 'pattern'
|
||||
);
|
||||
|
||||
// 개별 품목 규칙에서 item_ids 추출
|
||||
const individualRules = data.classificationRules.filter(
|
||||
(rule) => rule.registrationType === 'individual'
|
||||
);
|
||||
|
||||
// 개별 품목의 conditionValue에서 ID 배열 추출 (쉼표 구분)
|
||||
const itemIds: number[] = individualRules.flatMap((rule) =>
|
||||
rule.conditionValue
|
||||
.split(',')
|
||||
.map((id) => parseInt(id.trim(), 10))
|
||||
.filter((n) => !isNaN(n) && n > 0)
|
||||
);
|
||||
|
||||
return {
|
||||
process_name: data.processName,
|
||||
process_type: data.processType,
|
||||
@@ -105,8 +181,8 @@ function transformFrontendToApi(data: ProcessFormData): Record<string, unknown>
|
||||
work_steps: data.workSteps ? data.workSteps.split(',').map((s) => s.trim()).filter(Boolean) : [],
|
||||
note: data.note || null,
|
||||
is_active: data.isActive,
|
||||
classification_rules: data.classificationRules.map((rule) => ({
|
||||
registration_type: rule.registrationType,
|
||||
// 패턴 규칙만 전송 (registration_type 제외)
|
||||
classification_rules: patternRules.map((rule) => ({
|
||||
rule_type: rule.ruleType,
|
||||
matching_type: rule.matchingType,
|
||||
condition_value: rule.conditionValue,
|
||||
@@ -114,6 +190,8 @@ function transformFrontendToApi(data: ProcessFormData): Record<string, unknown>
|
||||
description: rule.description || null,
|
||||
is_active: rule.isActive,
|
||||
})),
|
||||
// 개별 품목 ID 배열 전송
|
||||
item_ids: itemIds,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -494,8 +572,8 @@ export async function getDepartmentOptions(): Promise<DepartmentOption[]> {
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
return result.data.map((dept: { id: number; name: string }) => ({
|
||||
if (result.success && result.data?.data) {
|
||||
return result.data.data.map((dept: { id: number; name: string }) => ({
|
||||
value: dept.name,
|
||||
label: dept.name,
|
||||
}));
|
||||
@@ -552,12 +630,12 @@ export async function getItemList(params?: GetItemListParams): Promise<ItemOptio
|
||||
|
||||
const result = await response.json();
|
||||
if (result.success && result.data?.data) {
|
||||
return result.data.data.map((item: { id: number; item_name: string; item_code?: string; item_type?: string }) => ({
|
||||
return result.data.data.map((item: { id: number; name: string; code?: string; item_type?: string }) => ({
|
||||
value: String(item.id),
|
||||
label: item.item_name,
|
||||
code: item.item_code || '',
|
||||
label: item.name,
|
||||
code: item.code || '',
|
||||
id: String(item.id),
|
||||
fullName: item.item_name,
|
||||
fullName: item.name,
|
||||
type: item.item_type || '',
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -21,6 +21,18 @@ import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { getSalesOrdersForWorkOrder } from './actions';
|
||||
import type { SalesOrder } from './types';
|
||||
|
||||
// Debounce 훅
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
interface SalesOrderSelectModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -36,12 +48,15 @@ export function SalesOrderSelectModal({
|
||||
const [salesOrders, setSalesOrders] = useState<SalesOrder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 디바운스된 검색어 (300ms 딜레이)
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// API로 수주 목록 로드
|
||||
const loadSalesOrders = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getSalesOrdersForWorkOrder({
|
||||
q: searchTerm || undefined,
|
||||
q: debouncedSearchTerm || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
// API 응답을 SalesOrder 타입으로 변환
|
||||
@@ -66,7 +81,7 @@ export function SalesOrderSelectModal({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [searchTerm]);
|
||||
}, [debouncedSearchTerm]);
|
||||
|
||||
// 모달이 열릴 때 데이터 로드
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* API 연동 완료 (2025-12-26)
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft, FileText, X, Edit2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -26,7 +26,7 @@ import { SalesOrderSelectModal } from './SalesOrderSelectModal';
|
||||
import { AssigneeSelectModal } from './AssigneeSelectModal';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { createWorkOrder } from './actions';
|
||||
import { createWorkOrder, getProcessOptions, type ProcessOption } from './actions';
|
||||
import { PROCESS_TYPE_LABELS, type ProcessType, type SalesOrder } from './types';
|
||||
|
||||
// Validation 에러 타입
|
||||
@@ -39,6 +39,7 @@ const FIELD_NAME_MAP: Record<string, string> = {
|
||||
selectedOrder: '수주',
|
||||
client: '발주처',
|
||||
projectName: '현장명',
|
||||
processId: '공정',
|
||||
shipmentDate: '출고예정일',
|
||||
};
|
||||
|
||||
@@ -56,7 +57,7 @@ interface FormData {
|
||||
itemCount: number;
|
||||
|
||||
// 작업지시 정보
|
||||
processType: ProcessType;
|
||||
processId: number | null; // 공정 ID (FK → processes.id)
|
||||
shipmentDate: string;
|
||||
priority: number;
|
||||
assignees: string[];
|
||||
@@ -72,7 +73,7 @@ const initialFormData: FormData = {
|
||||
projectName: '',
|
||||
orderNo: '',
|
||||
itemCount: 0,
|
||||
processType: 'screen',
|
||||
processId: null,
|
||||
shipmentDate: '',
|
||||
priority: 5,
|
||||
assignees: [],
|
||||
@@ -88,6 +89,27 @@ export function WorkOrderCreate() {
|
||||
const [assigneeNames, setAssigneeNames] = useState<string[]>([]);
|
||||
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [processOptions, setProcessOptions] = useState<ProcessOption[]>([]);
|
||||
const [isLoadingProcesses, setIsLoadingProcesses] = useState(true);
|
||||
|
||||
// 공정 옵션 로드
|
||||
useEffect(() => {
|
||||
async function loadProcessOptions() {
|
||||
setIsLoadingProcesses(true);
|
||||
const result = await getProcessOptions();
|
||||
if (result.success) {
|
||||
setProcessOptions(result.data);
|
||||
// 첫 번째 공정을 기본값으로 설정
|
||||
if (result.data.length > 0 && !formData.processId) {
|
||||
setFormData(prev => ({ ...prev, processId: result.data[0].id }));
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '공정 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
setIsLoadingProcesses(false);
|
||||
}
|
||||
loadProcessOptions();
|
||||
}, []);
|
||||
|
||||
// 수주 선택 핸들러
|
||||
const handleSelectOrder = (order: SalesOrder) => {
|
||||
@@ -105,7 +127,7 @@ export function WorkOrderCreate() {
|
||||
const handleClearOrder = () => {
|
||||
setFormData({
|
||||
...initialFormData,
|
||||
processType: formData.processType,
|
||||
processId: formData.processId,
|
||||
shipmentDate: formData.shipmentDate,
|
||||
priority: formData.priority,
|
||||
});
|
||||
@@ -129,6 +151,10 @@ export function WorkOrderCreate() {
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.processId) {
|
||||
errors.processId = '공정을 선택해주세요';
|
||||
}
|
||||
|
||||
if (!formData.shipmentDate) {
|
||||
errors.shipmentDate = '출고예정일을 선택해주세요';
|
||||
}
|
||||
@@ -150,9 +176,9 @@ export function WorkOrderCreate() {
|
||||
const result = await createWorkOrder({
|
||||
salesOrderId: formData.selectedOrder?.id ? parseInt(formData.selectedOrder.id) : undefined,
|
||||
projectName: formData.projectName,
|
||||
processType: formData.processType,
|
||||
processId: formData.processId!, // 공정 ID (FK → processes.id)
|
||||
scheduledDate: formData.shipmentDate,
|
||||
assigneeId: formData.assignees.length > 0 ? parseInt(formData.assignees[0]) : undefined,
|
||||
assigneeIds: formData.assignees.map(id => parseInt(id)),
|
||||
memo: formData.note || undefined,
|
||||
});
|
||||
|
||||
@@ -176,14 +202,10 @@ export function WorkOrderCreate() {
|
||||
router.back();
|
||||
};
|
||||
|
||||
// 공정 코드 표시
|
||||
const getProcessCode = (type: ProcessType) => {
|
||||
const codes: Record<ProcessType, string> = {
|
||||
screen: 'P-001 | 작업일지: WL-SCR',
|
||||
slat: 'P-002 | 작업일지: WL-SLT',
|
||||
bending: 'P-003 | 작업일지: WL-FLD',
|
||||
};
|
||||
return codes[type];
|
||||
// 선택된 공정의 코드 가져오기
|
||||
const getSelectedProcessCode = (): string => {
|
||||
const selectedProcess = processOptions.find(p => p.id === formData.processId);
|
||||
return selectedProcess?.processCode || '-';
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -398,22 +420,23 @@ export function WorkOrderCreate() {
|
||||
<div className="space-y-2">
|
||||
<Label>공정구분 *</Label>
|
||||
<Select
|
||||
value={formData.processType}
|
||||
onValueChange={(value) => setFormData({ ...formData, processType: value as ProcessType })}
|
||||
value={formData.processId?.toString() || ''}
|
||||
onValueChange={(value) => setFormData({ ...formData, processId: parseInt(value) })}
|
||||
disabled={isLoadingProcesses}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue />
|
||||
<SelectValue placeholder={isLoadingProcesses ? '로딩 중...' : '공정을 선택하세요'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(PROCESS_TYPE_LABELS).map(([key, label]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{label}
|
||||
{processOptions.map((process) => (
|
||||
<SelectItem key={process.id} value={process.id.toString()}>
|
||||
{process.processName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
공정코드: {getProcessCode(formData.processType)}
|
||||
공정코드: {getSelectedProcessCode()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, List, AlertTriangle, Play, CheckCircle2 } from 'lucide-react';
|
||||
import { FileText, List, AlertTriangle, Play, CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -23,9 +23,8 @@ import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { WorkLogModal } from '../WorkerScreen/WorkLogModal';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { getWorkOrderById } from './actions';
|
||||
import { getWorkOrderById, updateWorkOrderStatus, updateWorkOrderItemStatus, type WorkOrderItemStatus } from './actions';
|
||||
import {
|
||||
PROCESS_TYPE_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_STATUS_COLORS,
|
||||
ITEM_STATUS_LABELS,
|
||||
@@ -35,33 +34,47 @@ import {
|
||||
BENDING_PROCESS_STEPS,
|
||||
type WorkOrder,
|
||||
type ProcessType,
|
||||
type ProcessStep,
|
||||
} from './types';
|
||||
|
||||
// 공정 진행 단계 컴포넌트
|
||||
function ProcessSteps({
|
||||
processType,
|
||||
currentStep,
|
||||
workSteps,
|
||||
}: {
|
||||
processType: ProcessType;
|
||||
currentStep: number;
|
||||
workSteps?: ProcessStep[];
|
||||
}) {
|
||||
const steps =
|
||||
processType === 'screen'
|
||||
// 동적 workSteps 우선 사용, 없으면 하드코딩 폴백
|
||||
const steps = workSteps && workSteps.length > 0
|
||||
? workSteps
|
||||
: processType === 'screen'
|
||||
? SCREEN_PROCESS_STEPS
|
||||
: processType === 'slat'
|
||||
? SLAT_PROCESS_STEPS
|
||||
: BENDING_PROCESS_STEPS;
|
||||
? SLAT_PROCESS_STEPS
|
||||
: BENDING_PROCESS_STEPS;
|
||||
|
||||
if (steps.length === 0) {
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">공정 진행</h3>
|
||||
<p className="text-gray-500">공정 단계가 설정되지 않았습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
<h3 className="font-semibold mb-4">공정 진행 ({steps.length}단계)</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{steps.map((step, index) => {
|
||||
const isCompleted = index < currentStep;
|
||||
const isCurrent = index === currentStep;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center">
|
||||
<div key={step.key || `step-${index}`} className="flex items-center">
|
||||
<div
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-full border ${
|
||||
isCompleted
|
||||
@@ -193,6 +206,8 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
const [isWorkLogOpen, setIsWorkLogOpen] = useState(false);
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isStatusUpdating, setIsStatusUpdating] = useState(false);
|
||||
const [updatingItemId, setUpdatingItemId] = useState<number | null>(null);
|
||||
|
||||
// API에서 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -205,7 +220,6 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
toast.error(result.error || '작업지시 조회에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderDetail] loadData error:', error);
|
||||
toast.error('데이터 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
@@ -217,12 +231,83 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// 상태 변경 핸들러
|
||||
const handleStatusChange = useCallback(async (newStatus: 'waiting' | 'in_progress' | 'completed') => {
|
||||
if (!order) return;
|
||||
|
||||
setIsStatusUpdating(true);
|
||||
try {
|
||||
const result = await updateWorkOrderStatus(orderId, newStatus);
|
||||
if (result.success && result.data) {
|
||||
setOrder(result.data);
|
||||
const statusLabels = {
|
||||
waiting: '작업대기',
|
||||
in_progress: '작업중',
|
||||
completed: '작업완료',
|
||||
};
|
||||
toast.success(`상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`);
|
||||
} else {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderDetail] handleStatusChange error:', error);
|
||||
toast.error('상태 변경 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsStatusUpdating(false);
|
||||
}
|
||||
}, [order, orderId]);
|
||||
|
||||
// 품목 상태 변경 핸들러
|
||||
const handleItemStatusChange = useCallback(async (itemId: number, newStatus: WorkOrderItemStatus) => {
|
||||
if (!order) return;
|
||||
|
||||
setUpdatingItemId(itemId);
|
||||
try {
|
||||
const result = await updateWorkOrderItemStatus(orderId, itemId, newStatus);
|
||||
if (result.success) {
|
||||
// 로컬 상태 업데이트 (품목 + 작업지시 상태)
|
||||
setOrder(prev => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
status: result.workOrderStatus || prev.status,
|
||||
items: prev.items.map(item =>
|
||||
item.id === itemId ? { ...item, status: newStatus } : item
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const statusLabels: Record<WorkOrderItemStatus, string> = {
|
||||
waiting: '대기',
|
||||
in_progress: '작업중',
|
||||
completed: '완료',
|
||||
};
|
||||
toast.success(`품목 상태가 '${statusLabels[newStatus]}'(으)로 변경되었습니다.`);
|
||||
|
||||
// 작업지시 상태가 변경된 경우 추가 알림
|
||||
if (result.workOrderStatusChanged && result.workOrderStatus) {
|
||||
const workOrderStatusLabel = WORK_ORDER_STATUS_LABELS[result.workOrderStatus as keyof typeof WORK_ORDER_STATUS_LABELS] || result.workOrderStatus;
|
||||
toast.info(`작업지시 상태가 '${workOrderStatusLabel}'(으)로 자동 변경되었습니다.`);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '품목 상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[WorkOrderDetail] handleItemStatusChange error:', error);
|
||||
toast.error('품목 상태 변경 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setUpdatingItemId(null);
|
||||
}
|
||||
}, [order, orderId]);
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<h1 className="text-2xl font-bold mb-6">작업지시 상세</h1>
|
||||
<ContentLoadingSpinner text="작업지시 정보를 불러오는 중..." />
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -249,7 +334,9 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
quantity: order.items.reduce((sum, item) => sum + item.quantity, 0),
|
||||
progress: order.currentStep * 20, // 대략적인 진행률
|
||||
process: order.processType as 'screen' | 'slat' | 'bending',
|
||||
assignees: [order.assignee],
|
||||
assignees: order.assignees && order.assignees.length > 0
|
||||
? order.assignees.map(a => a.name)
|
||||
: [order.assignee],
|
||||
instruction: order.note || '',
|
||||
status: 'in_progress' as const,
|
||||
priority: order.priority <= 3 ? 'high' : order.priority <= 6 ? 'medium' : 'low',
|
||||
@@ -261,6 +348,35 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">작업지시 상세</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 상태 변경 버튼 */}
|
||||
{order.status === 'waiting' && (
|
||||
<Button
|
||||
onClick={() => handleStatusChange('in_progress')}
|
||||
disabled={isStatusUpdating}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
{isStatusUpdating ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
작업 시작
|
||||
</Button>
|
||||
)}
|
||||
{order.status === 'in_progress' && (
|
||||
<Button
|
||||
onClick={() => handleStatusChange('completed')}
|
||||
disabled={isStatusUpdating}
|
||||
className="bg-purple-600 hover:bg-purple-700"
|
||||
>
|
||||
{isStatusUpdating ? (
|
||||
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-4 h-4 mr-1.5" />
|
||||
)}
|
||||
작업 완료
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => setIsWorkLogOpen(true)}>
|
||||
<FileText className="w-4 h-4 mr-1.5" />
|
||||
작업일지
|
||||
@@ -287,7 +403,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">공정구분</p>
|
||||
<p className="font-medium">{PROCESS_TYPE_LABELS[order.processType]}</p>
|
||||
<p className="font-medium">{order.processName}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업상태</p>
|
||||
@@ -309,13 +425,21 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground mb-1">작업자</p>
|
||||
<p className="font-medium">{order.assignee}</p>
|
||||
<p className="font-medium">
|
||||
{order.assignees && order.assignees.length > 0
|
||||
? order.assignees.map(a => a.name).join(', ')
|
||||
: order.assignee}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공정 진행 */}
|
||||
<ProcessSteps processType={order.processType} currentStep={order.currentStep} />
|
||||
<ProcessSteps
|
||||
processType={order.processType}
|
||||
currentStep={order.currentStep}
|
||||
workSteps={order.workSteps}
|
||||
/>
|
||||
|
||||
{/* 작업 품목 */}
|
||||
<div className="bg-white border rounded-lg p-6">
|
||||
@@ -346,14 +470,32 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
|
||||
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||
<TableCell>
|
||||
{item.status === 'waiting' && (
|
||||
<Button variant="outline" size="sm">
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleItemStatusChange(item.id, 'in_progress')}
|
||||
disabled={updatingItemId === item.id}
|
||||
>
|
||||
{updatingItemId === item.id ? (
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
시작
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'in_progress' && (
|
||||
<Button variant="outline" size="sm">
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleItemStatusChange(item.id, 'completed')}
|
||||
disabled={updatingItemId === item.id}
|
||||
>
|
||||
{updatingItemId === item.id ? (
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
완료
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,6 @@ import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard
|
||||
import { toast } from 'sonner';
|
||||
import { getWorkOrders, getWorkOrderStats } from './actions';
|
||||
import {
|
||||
PROCESS_TYPE_LABELS,
|
||||
WORK_ORDER_STATUS_LABELS,
|
||||
WORK_ORDER_STATUS_COLORS,
|
||||
type WorkOrder,
|
||||
@@ -31,6 +30,18 @@ import {
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// Debounce 훅
|
||||
function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
// 탭 필터 정의
|
||||
type TabFilter = 'all' | 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed';
|
||||
|
||||
@@ -44,6 +55,9 @@ export function WorkOrderList() {
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
// 디바운스된 검색어 (300ms 딜레이)
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// API 데이터 상태
|
||||
const [workOrders, setWorkOrders] = useState<WorkOrder[]>([]);
|
||||
const [statsData, setStatsData] = useState<WorkOrderStats>({
|
||||
@@ -58,45 +72,56 @@ export function WorkOrderList() {
|
||||
const [totalItems, setTotalItems] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 목록과 통계를 병렬로 조회
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getWorkOrders({
|
||||
page: currentPage,
|
||||
perPage: ITEMS_PER_PAGE,
|
||||
status: activeTab === 'all' ? undefined : activeTab,
|
||||
search: searchTerm || undefined,
|
||||
}),
|
||||
getWorkOrderStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success) {
|
||||
setWorkOrders(listResult.data);
|
||||
setTotalItems(listResult.pagination.total);
|
||||
setTotalPages(listResult.pagination.lastPage);
|
||||
} else {
|
||||
toast.error(listResult.error || '목록 조회에 실패했습니다.');
|
||||
}
|
||||
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatsData(statsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderList] loadData error:', error);
|
||||
toast.error('데이터 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentPage, activeTab, searchTerm]);
|
||||
|
||||
// 초기 로드 및 필터 변경 시 데이터 다시 로드
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 목록과 통계를 병렬로 조회
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getWorkOrders({
|
||||
page: currentPage,
|
||||
perPage: ITEMS_PER_PAGE,
|
||||
status: activeTab === 'all' ? undefined : activeTab,
|
||||
search: debouncedSearchTerm || undefined,
|
||||
}),
|
||||
getWorkOrderStats(),
|
||||
]);
|
||||
|
||||
// 컴포넌트가 언마운트되었으면 상태 업데이트 중단
|
||||
if (!isMounted) return;
|
||||
|
||||
if (listResult.success) {
|
||||
setWorkOrders(listResult.data);
|
||||
setTotalItems(listResult.pagination.total);
|
||||
setTotalPages(listResult.pagination.lastPage);
|
||||
} else {
|
||||
toast.error(listResult.error || '목록 조회에 실패했습니다.');
|
||||
}
|
||||
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStatsData(statsResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isMounted) return;
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderList] loadData error:', error);
|
||||
toast.error('데이터 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
if (isMounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [currentPage, activeTab, debouncedSearchTerm]);
|
||||
|
||||
// 탭 옵션 (통계 데이터 기반)
|
||||
const tabs: TabOption[] = [
|
||||
@@ -224,7 +249,7 @@ export function WorkOrderList() {
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{order.workOrderNo}</TableCell>
|
||||
<TableCell>{PROCESS_TYPE_LABELS[order.processType]}</TableCell>
|
||||
<TableCell>{order.processName}</TableCell>
|
||||
<TableCell>{order.lotNo}</TableCell>
|
||||
<TableCell>{order.orderDate}</TableCell>
|
||||
<TableCell className="text-center">{order.isAssigned ? 'Y' : '-'}</TableCell>
|
||||
@@ -273,7 +298,7 @@ export function WorkOrderList() {
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="공정" value={PROCESS_TYPE_LABELS[order.processType]} />
|
||||
<InfoField label="공정" value={order.processName} />
|
||||
<InfoField label="로트번호" value={order.lotNo} />
|
||||
<InfoField label="발주처" value={order.client} />
|
||||
<InfoField label="작업자" value={order.assignee || '-'} />
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* - PATCH /api/v1/work-orders/{id}/bending/toggle - 벤딩 필드 토글
|
||||
* - POST /api/v1/work-orders/{id}/issues - 이슈 등록
|
||||
* - PATCH /api/v1/work-orders/{id}/issues/{issueId}/resolve - 이슈 해결
|
||||
* - PATCH /api/v1/work-orders/{id}/items/{itemId}/status - 품목 상태 변경
|
||||
*/
|
||||
|
||||
'use server';
|
||||
@@ -24,7 +25,6 @@ import type {
|
||||
WorkOrder,
|
||||
WorkOrderStats,
|
||||
WorkOrderStatus,
|
||||
ProcessType,
|
||||
WorkOrderApiPaginatedResponse,
|
||||
WorkOrderStatsApi,
|
||||
} from './types';
|
||||
@@ -47,7 +47,7 @@ export async function getWorkOrders(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
status?: WorkOrderStatus | 'all';
|
||||
processType?: ProcessType | 'all';
|
||||
processId?: number | 'all'; // 공정 ID (FK → processes.id)
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
@@ -71,8 +71,8 @@ export async function getWorkOrders(params?: {
|
||||
if (params?.status && params.status !== 'all') {
|
||||
searchParams.set('status', params.status);
|
||||
}
|
||||
if (params?.processType && params.processType !== 'all') {
|
||||
searchParams.set('process_type', params.processType);
|
||||
if (params?.processId && params.processId !== 'all') {
|
||||
searchParams.set('process_id', String(params.processId));
|
||||
}
|
||||
if (params?.search) searchParams.set('search', params.search);
|
||||
if (params?.startDate) searchParams.set('start_date', params.startDate);
|
||||
@@ -220,15 +220,23 @@ export async function getWorkOrderById(id: string): Promise<{
|
||||
export async function createWorkOrder(
|
||||
data: Partial<WorkOrder> & {
|
||||
salesOrderId?: number;
|
||||
assigneeId?: number;
|
||||
assigneeId?: number; // 단일 담당자 (하위 호환)
|
||||
assigneeIds?: number[]; // 다중 담당자
|
||||
teamId?: number;
|
||||
}
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
// 다중 담당자 우선, 없으면 단일 담당자 배열로 변환
|
||||
const assigneeIds = data.assigneeIds && data.assigneeIds.length > 0
|
||||
? data.assigneeIds
|
||||
: data.assigneeId
|
||||
? [data.assigneeId]
|
||||
: undefined;
|
||||
|
||||
const apiData = {
|
||||
...transformFrontendToApi(data),
|
||||
sales_order_id: data.salesOrderId,
|
||||
assignee_id: data.assigneeId,
|
||||
assignee_ids: assigneeIds, // 배열로 전송
|
||||
team_id: data.teamId,
|
||||
};
|
||||
|
||||
@@ -384,11 +392,13 @@ export async function updateWorkOrderStatus(
|
||||
// ===== 담당자 배정 =====
|
||||
export async function assignWorkOrder(
|
||||
id: string,
|
||||
assigneeId: number,
|
||||
assigneeIds: number | number[], // 단일 또는 다중 담당자
|
||||
teamId?: number
|
||||
): Promise<{ success: boolean; data?: WorkOrder; error?: string }> {
|
||||
try {
|
||||
const body: { assignee_id: number; team_id?: number } = { assignee_id: assigneeId };
|
||||
// 배열로 통일
|
||||
const ids = Array.isArray(assigneeIds) ? assigneeIds : [assigneeIds];
|
||||
const body: { assignee_ids: number[]; team_id?: number } = { assignee_ids: ids };
|
||||
if (teamId) body.team_id = teamId;
|
||||
|
||||
console.log('[WorkOrderActions] PATCH assign request:', body);
|
||||
@@ -550,6 +560,62 @@ export async function resolveWorkOrderIssue(
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 품목 상태 변경 =====
|
||||
export type WorkOrderItemStatus = 'waiting' | 'in_progress' | 'completed';
|
||||
|
||||
export async function updateWorkOrderItemStatus(
|
||||
workOrderId: string,
|
||||
itemId: number,
|
||||
status: WorkOrderItemStatus
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
itemId: number;
|
||||
status: WorkOrderItemStatus;
|
||||
workOrderStatus?: string;
|
||||
workOrderStatusChanged?: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
console.log('[WorkOrderActions] PATCH item status request:', { workOrderId, itemId, status });
|
||||
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/items/${itemId}/status`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ status }),
|
||||
}
|
||||
);
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, itemId, status, error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('[WorkOrderActions] PATCH item status response:', result);
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
return {
|
||||
success: false,
|
||||
itemId,
|
||||
status,
|
||||
error: result.message || '품목 상태 변경에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
itemId,
|
||||
status: result.data?.item?.status || status,
|
||||
workOrderStatus: result.data?.work_order_status,
|
||||
workOrderStatusChanged: result.data?.work_order_status_changed || false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] updateWorkOrderItemStatus error:', error);
|
||||
return { success: false, itemId, status, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 수주 목록 조회 (작업지시 생성용) =====
|
||||
export interface SalesOrderForWorkOrder {
|
||||
id: number;
|
||||
@@ -579,9 +645,9 @@ export async function getSalesOrdersForWorkOrder(params?: {
|
||||
if (params?.status) searchParams.set('status', params.status);
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/sales-orders${queryString ? `?${queryString}` : ''}`;
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/orders${queryString ? `?${queryString}` : ''}`;
|
||||
|
||||
console.log('[WorkOrderActions] GET sales-orders for work-order:', url);
|
||||
console.log('[WorkOrderActions] GET orders for work-order:', url);
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
@@ -590,7 +656,7 @@ export async function getSalesOrdersForWorkOrder(params?: {
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkOrderActions] GET sales-orders error:', response.status);
|
||||
console.warn('[WorkOrderActions] GET orders error:', response.status);
|
||||
return { success: false, data: [], error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
@@ -717,3 +783,65 @@ export async function getDepartmentsWithUsers(): Promise<{
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 공정 목록 조회 (작업지시 생성용) =====
|
||||
export interface ProcessOption {
|
||||
id: number;
|
||||
processCode: string;
|
||||
processName: string;
|
||||
}
|
||||
|
||||
export async function getProcessOptions(): Promise<{
|
||||
success: boolean;
|
||||
data: ProcessOption[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/processes/options`;
|
||||
|
||||
console.log('[WorkOrderActions] GET process options:', url);
|
||||
|
||||
const { response, error } = await serverFetch(url, { method: 'GET' });
|
||||
|
||||
if (error || !response) {
|
||||
return { success: false, data: [], error: error?.message || 'API 요청 실패' };
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[WorkOrderActions] GET process options error:', response.status);
|
||||
return { success: false, data: [], error: `API 오류: ${response.status}` };
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
error: result.message || '공정 목록 조회에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
// API 응답 변환
|
||||
const processes: ProcessOption[] = (result.data || []).map(
|
||||
(item: {
|
||||
id: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
}) => ({
|
||||
id: item.id,
|
||||
processCode: item.process_code,
|
||||
processName: item.process_name,
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: processes,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[WorkOrderActions] getProcessOptions error:', error);
|
||||
return { success: false, data: [], error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@
|
||||
* 작업지시 관리 타입 정의
|
||||
*/
|
||||
|
||||
// 공정 구분
|
||||
// 공정 정보 (API 관계)
|
||||
export interface ProcessInfo {
|
||||
id: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
}
|
||||
|
||||
// @deprecated process_type은 process_id FK로 변경됨
|
||||
// 하위 호환성을 위해 유지
|
||||
export type ProcessType = 'screen' | 'slat' | 'bending';
|
||||
|
||||
export const PROCESS_TYPE_LABELS: Record<ProcessType, string> = {
|
||||
@@ -134,22 +142,36 @@ export const ISSUE_STATUS_LABELS: Record<WorkOrderIssue['status'], string> = {
|
||||
resolved: '해결됨',
|
||||
};
|
||||
|
||||
// 공정 단계 타입
|
||||
export interface ProcessStep {
|
||||
key: string;
|
||||
label: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
// 작업지시 메인 타입
|
||||
export interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNo: string; // 작업지시번호 (KD-WO-251217-12)
|
||||
lotNo: string; // 로트번호 (KD-TS-251217-10)
|
||||
processType: ProcessType; // 공정구분
|
||||
processId: number; // 공정 ID (FK)
|
||||
processName: string; // 공정명 (표시용)
|
||||
processCode: string; // 공정코드 (표시용)
|
||||
workSteps?: ProcessStep[]; // 공정 단계 (동적, DB에서 로드)
|
||||
/** @deprecated process_id FK 사용 */
|
||||
processType: ProcessType; // 하위 호환용
|
||||
status: WorkOrderStatus; // 작업상태
|
||||
|
||||
// 기본 정보
|
||||
client: string; // 발주처
|
||||
projectName: string; // 현장명
|
||||
dueDate: string; // 납기일
|
||||
assignee: string; // 작업자
|
||||
assignee: string; // 작업자 (주 담당자)
|
||||
assignees?: { id: string; name: string; isPrimary: boolean }[]; // 다중 담당자
|
||||
|
||||
// 날짜 정보
|
||||
orderDate: string; // 지시일
|
||||
scheduledDate: string; // 예정일 (API: scheduled_date)
|
||||
shipmentDate: string; // 출고예정일
|
||||
|
||||
// 플래그
|
||||
@@ -237,6 +259,17 @@ export interface WorkOrderBendingDetailApi {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// API 응답 - 담당자 (다중 담당자)
|
||||
export interface WorkOrderAssigneeApi {
|
||||
id: number;
|
||||
work_order_id: number;
|
||||
user_id: number;
|
||||
is_primary: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user?: { id: number; name: string };
|
||||
}
|
||||
|
||||
// API 응답 - 이슈
|
||||
export interface WorkOrderIssueApi {
|
||||
id: number;
|
||||
@@ -259,7 +292,7 @@ export interface WorkOrderApi {
|
||||
work_order_no: string;
|
||||
sales_order_id: number | null;
|
||||
project_name: string | null;
|
||||
process_type: 'screen' | 'slat' | 'bending';
|
||||
process_id: number; // FK to processes.id
|
||||
status: 'unassigned' | 'pending' | 'waiting' | 'in_progress' | 'completed' | 'shipped';
|
||||
assignee_id: number | null;
|
||||
team_id: number | null;
|
||||
@@ -277,7 +310,14 @@ export interface WorkOrderApi {
|
||||
order_no: string;
|
||||
client?: { id: number; name: string };
|
||||
};
|
||||
process?: {
|
||||
id: number;
|
||||
process_code: string;
|
||||
process_name: string;
|
||||
work_steps?: string[] | { key: string; label: string; order: number }[];
|
||||
};
|
||||
assignee?: { id: number; name: string };
|
||||
assignees?: WorkOrderAssigneeApi[];
|
||||
team?: { id: number; name: string };
|
||||
items?: WorkOrderItemApi[];
|
||||
bending_detail?: WorkOrderBendingDetailApi;
|
||||
@@ -308,19 +348,53 @@ export interface WorkOrderStatsApi {
|
||||
|
||||
// API → Frontend 변환
|
||||
export function transformApiToFrontend(api: WorkOrderApi): WorkOrder {
|
||||
// 다중 담당자 변환
|
||||
const assignees = (api.assignees || []).map(a => ({
|
||||
id: String(a.user_id),
|
||||
name: a.user?.name || '-',
|
||||
isPrimary: a.is_primary,
|
||||
}));
|
||||
|
||||
// 주 담당자 이름 (기존 호환)
|
||||
const primaryAssignee = assignees.find(a => a.isPrimary);
|
||||
const assigneeName = primaryAssignee?.name || api.assignee?.name || '-';
|
||||
|
||||
// 공정명 → 하위호환용 processType 매핑
|
||||
const processNameToType = (name: string): ProcessType => {
|
||||
const mapping: Record<string, ProcessType> = {
|
||||
'스크린': 'screen',
|
||||
'슬랫': 'slat',
|
||||
'절곡': 'bending',
|
||||
};
|
||||
return mapping[name] || 'screen';
|
||||
};
|
||||
|
||||
return {
|
||||
id: String(api.id),
|
||||
workOrderNo: api.work_order_no,
|
||||
lotNo: api.sales_order?.order_no || '-',
|
||||
processType: api.process_type,
|
||||
processId: api.process_id,
|
||||
processName: api.process?.process_name || '-',
|
||||
processCode: api.process?.process_code || '-',
|
||||
// work_steps: string[] 또는 ProcessStep[] 형식 모두 지원
|
||||
workSteps: api.process?.work_steps
|
||||
? (api.process.work_steps as (string | { key: string; label: string; order: number })[]).map((step, idx) =>
|
||||
typeof step === 'string'
|
||||
? { key: `step-${idx}`, label: step, order: idx + 1 }
|
||||
: step
|
||||
)
|
||||
: undefined,
|
||||
processType: processNameToType(api.process?.process_name || ''), // 하위 호환
|
||||
status: api.status,
|
||||
client: api.sales_order?.client?.name || '-',
|
||||
projectName: api.project_name || '-',
|
||||
dueDate: api.scheduled_date || '-',
|
||||
assignee: api.assignee?.name || '-',
|
||||
assignee: assigneeName,
|
||||
assignees: assignees.length > 0 ? assignees : undefined,
|
||||
orderDate: api.created_at.split('T')[0],
|
||||
scheduledDate: api.scheduled_date || '',
|
||||
shipmentDate: api.scheduled_date || '-',
|
||||
isAssigned: api.assignee_id !== null,
|
||||
isAssigned: api.assignee_id !== null || assignees.length > 0,
|
||||
isStarted: ['in_progress', 'completed', 'shipped'].includes(api.status),
|
||||
priority: 5, // Default priority
|
||||
currentStep: getStatusStep(api.status),
|
||||
@@ -387,13 +461,14 @@ function getStatusStep(status: WorkOrderStatus): number {
|
||||
}
|
||||
|
||||
// Frontend → API 변환 (등록/수정용)
|
||||
export function transformFrontendToApi(data: Partial<WorkOrder>): Record<string, unknown> {
|
||||
export function transformFrontendToApi(data: Partial<WorkOrder> & { processId?: number }): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
if (data.projectName !== undefined) result.project_name = data.projectName;
|
||||
if (data.processType !== undefined) result.process_type = data.processType;
|
||||
if (data.processId !== undefined) result.process_id = data.processId;
|
||||
if (data.status !== undefined) result.status = data.status;
|
||||
if (data.dueDate !== undefined) result.scheduled_date = data.dueDate;
|
||||
if (data.scheduledDate !== undefined) result.scheduled_date = data.scheduledDate;
|
||||
if (data.dueDate !== undefined && data.scheduledDate === undefined) result.scheduled_date = data.dueDate;
|
||||
if (data.note !== undefined) result.memo = data.note;
|
||||
|
||||
// items 변환
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface QuoteItem {
|
||||
id: string;
|
||||
quoteId: string;
|
||||
productId?: string;
|
||||
itemCode?: string; // 품목코드 (item_code)
|
||||
productName: string;
|
||||
specification?: string;
|
||||
unit?: string;
|
||||
@@ -298,6 +299,7 @@ export function transformItemApiToFrontend(apiData: QuoteItemApiData): QuoteItem
|
||||
id: String(apiData.id),
|
||||
quoteId: String(apiData.quote_id),
|
||||
productId: apiData.item_id ? String(apiData.item_id) : (apiData.product_id ? String(apiData.product_id) : undefined),
|
||||
itemCode: apiData.item_code || undefined, // 품목코드
|
||||
productName,
|
||||
specification: apiData.specification || undefined,
|
||||
unit: apiData.unit || undefined,
|
||||
@@ -545,7 +547,7 @@ export function transformQuoteToFormData(quote: Quote): QuoteFormData {
|
||||
? quote.items.map((item, index) => ({
|
||||
itemIndex: index,
|
||||
finishedGoodsCode: '',
|
||||
itemCode: item.productId || item.id || '',
|
||||
itemCode: item.itemCode || '', // 품목코드 사용
|
||||
itemName: item.productName,
|
||||
itemType: '',
|
||||
itemCategory: '',
|
||||
|
||||
Reference in New Issue
Block a user