feat(WEB): 건설 노무/협력사/현장관리 개선

- 노무관리 actions API 연동 개선
- 협력사 폼 및 actions 개선
- 현장관리 actions 추가
This commit is contained in:
2026-01-13 19:47:57 +09:00
parent 8083c0e015
commit 777872486a
4 changed files with 261 additions and 203 deletions

View File

@@ -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: '노임 일괄 삭제에 실패했습니다.' };

View File

@@ -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>

View File

@@ -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<string, string> = {
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<ApiPartner>(`/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<ApiPartner>('/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<ApiPartner>(`/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<ApiPartnerStats>('/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) {

View File

@@ -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: '현장 등록에 실패했습니다.' };
}
}