diff --git a/src/components/business/construction/labor-management/actions.ts b/src/components/business/construction/labor-management/actions.ts index b665f57a..95e6a6e9 100644 --- a/src/components/business/construction/labor-management/actions.ts +++ b/src/components/business/construction/labor-management/actions.ts @@ -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): Record { + const apiData: Record = {}; + + 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 = {}; - // 검색어 필터 - 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('/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(`/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('/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(`/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: '노임 일괄 삭제에 실패했습니다.' }; diff --git a/src/components/business/construction/partners/PartnerForm.tsx b/src/components/business/construction/partners/PartnerForm.tsx index 28c8ae4b..957868c4 100644 --- a/src/components/business/construction/partners/PartnerForm.tsx +++ b/src/components/business/construction/partners/PartnerForm.tsx @@ -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)} diff --git a/src/components/business/construction/partners/actions.ts b/src/components/business/construction/partners/actions.ts index 5b78610a..26d47738 100644 --- a/src/components/business/construction/partners/actions.ts +++ b/src/components/business/construction/partners/actions.ts @@ -74,6 +74,18 @@ function transformPartnerType(partnerType: Partner['partnerType']): string { return typeMap[partnerType] || 'SALES'; } +/** + * client_type → 구분 라벨 변환 + */ +function getPartnerTypeLabel(clientType: string | null | undefined): string { + const labelMap: Record = { + SALES: '매출', + PURCHASE: '매입', + BOTH: '복합', + }; + return labelMap[clientType || ''] || '매출'; +} + /** * API 응답 → Partner 타입 변환 */ @@ -109,7 +121,7 @@ function transformPartner(apiData: ApiPartner): Partner { badDebtToggle: apiData.has_bad_debt, memos: [], documents: [], - category: '', + category: getPartnerTypeLabel(apiData.client_type), paymentDay: 0, isBadDebt: apiData.has_bad_debt, isActive: apiData.is_active !== false, @@ -165,15 +177,20 @@ export async function getPartnerList(filter?: PartnerFilter): Promise<{ 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<{ - data: ApiPartner[]; - current_page: number; - per_page: number; - total: number; - last_page: number; + success: boolean; + data: { + data: ApiPartner[]; + current_page: number; + per_page: number; + total: number; + last_page: number; + }; }>('/clients', { params: queryParams }); - let items = (response.data || []).map(transformPartner); + const paginated = response.data; + let items = (paginated?.data || []).map(transformPartner); // 악성채권 필터 (프론트엔드에서 처리) if (filter?.badDebtFilter && filter.badDebtFilter !== 'all') { @@ -202,10 +219,10 @@ export async function getPartnerList(filter?: PartnerFilter): Promise<{ success: true, data: { items, - total: response.total || 0, - page: response.current_page || 1, - size: response.per_page || 20, - totalPages: response.last_page || 1, + total: paginated?.total || 0, + page: paginated?.current_page || 1, + size: paginated?.per_page || 20, + totalPages: paginated?.last_page || 1, }, }; } catch (error) { @@ -224,8 +241,9 @@ export async function getPartner(id: string): Promise<{ error?: string; }> { try { - const response = await apiClient.get(`/clients/${id}`); - return { success: true, data: transformPartner(response) }; + // 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('거래처 조회 오류:', error); return { success: false, error: '거래처를 찾을 수 없습니다.' }; @@ -243,8 +261,9 @@ export async function createPartner(data: PartnerFormData): Promise<{ }> { try { const apiData = transformPartnerToApi(data); - const response = await apiClient.post('/clients', apiData); - return { success: true, data: transformPartner(response) }; + // 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('거래처 등록 오류:', error); return { success: false, error: '거래처 등록에 실패했습니다.' }; @@ -262,8 +281,9 @@ export async function updatePartner(id: string, data: PartnerFormData): Promise< }> { try { const apiData = transformPartnerToApi(data); - const response = await apiClient.put(`/clients/${id}`, apiData); - return { success: true, data: transformPartner(response) }; + // 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('거래처 수정 오류:', error); return { success: false, error: '거래처 수정에 실패했습니다.' }; @@ -280,15 +300,17 @@ export async function getPartnerStats(): Promise<{ error?: string; }> { try { - const response = await apiClient.get('/clients/stats'); + // API 응답 구조: { success, data: {...stats} } + const response = await apiClient.get<{ success: boolean; data: ApiPartnerStats }>('/clients/stats'); + const stats = response.data; return { success: true, data: { - total: response.total || 0, + total: stats?.total || 0, unregistered: 0, - badDebt: response.badDebt || 0, - normal: response.normal || 0, + badDebt: stats?.badDebt || 0, + normal: stats?.normal || 0, }, }; } catch (error) { diff --git a/src/components/business/construction/site-management/actions.ts b/src/components/business/construction/site-management/actions.ts index 2ccaadca..2dab97ae 100644 --- a/src/components/business/construction/site-management/actions.ts +++ b/src/components/business/construction/site-management/actions.ts @@ -210,4 +210,40 @@ export async function deleteSites(ids: string[]): Promise<{ 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: '현장 등록에 실패했습니다.' }; + } } \ No newline at end of file