feat(시공사): 1.2 인수인계보고서 - Frontend API 연동

- Mock 데이터 제거, 실제 API 연동으로 변환
- apiRequest 헬퍼 함수 구현 (쿠키 기반 인증)
- 7개 API 함수 구현: list, stats, detail, create, update, delete, bulk-delete
- snake_case → camelCase 타입 변환 함수 추가
This commit is contained in:
2026-01-09 16:08:18 +09:00
parent d15a2037d7
commit 9d30555265

View File

@@ -1,128 +1,243 @@
'use server';
import type { HandoverReport, HandoverReportStats, HandoverReportDetail, HandoverReportFormData } from './types';
import { cookies } from 'next/headers';
import type {
HandoverReport,
HandoverReportDetail,
HandoverReportStats,
HandoverReportFormData,
ConstructionManager,
ContractItem,
ExternalEquipmentCost,
} from './types';
// 목업 데이터
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
* API 연동 버전
*/
// API 기본 URL
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.sam.kr';
const API_KEY = process.env.API_KEY || '';
/**
* API 요청 헬퍼 함수
*/
async function apiRequest<T>(
endpoint: string,
options: RequestInit = {}
): Promise<{ success: boolean; data?: T; error?: string; message?: string }> {
try {
const cookieStore = await cookies();
const accessToken = cookieStore.get('access_token')?.value;
const headers: Record<string, string> = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'X-API-KEY': API_KEY,
};
if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`;
}
const url = `${API_BASE_URL}/api/v1${endpoint}`;
console.log('🔵 [HandoverReport API]', options.method || 'GET', url);
const response = await fetch(url, {
...options,
headers: {
...headers,
...options.headers,
},
});
const result = await response.json();
console.log('🔵 [HandoverReport API] Response status:', response.status);
if (!response.ok) {
return {
success: false,
error: result.message || `API 오류: ${response.status}`,
};
}
return {
success: result.success ?? true,
data: result.data,
message: result.message,
};
} catch (error) {
console.error('API request error:', error);
return {
success: false,
error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
};
}
}
/**
* API 응답 → 프론트엔드 타입 변환 (목록용)
*/
function transformHandoverReport(apiData: Record<string, unknown>): HandoverReport {
return {
id: String(apiData.id),
reportNumber: String(apiData.report_number || ''),
partnerName: String(apiData.partner_name || ''),
siteName: String(apiData.site_name || ''),
contractManagerName: String(apiData.contract_manager_name || ''),
constructionPMName: apiData.construction_pm_name ? String(apiData.construction_pm_name) : null,
totalSites: Number(apiData.total_sites || 0),
contractAmount: Number(apiData.contract_amount || 0),
contractStartDate: apiData.contract_start_date ? String(apiData.contract_start_date) : null,
contractEndDate: apiData.contract_end_date ? String(apiData.contract_end_date) : null,
status: (apiData.status as 'pending' | 'completed') || 'pending',
contractId: String(apiData.contract_id || ''),
createdAt: String(apiData.created_at || ''),
updatedAt: String(apiData.updated_at || ''),
};
}
/**
* API 응답 → 프론트엔드 타입 변환 (상세용)
*/
function transformHandoverReportDetail(apiData: Record<string, unknown>): HandoverReportDetail {
// 공사담당자 목록 변환
const managersData = apiData.managers as Record<string, unknown>[] | undefined;
const constructionManagers: ConstructionManager[] = (managersData || []).map((m) => ({
id: String(m.id || ''),
name: String(m.name || ''),
nonPerformanceReason: String(m.non_performance_reason || ''),
signature: m.signature ? String(m.signature) : null,
}));
// 계약 ITEM 목록 변환
const itemsData = apiData.items as Record<string, unknown>[] | undefined;
const contractItems: ContractItem[] = (itemsData || []).map((item) => ({
id: String(item.id || ''),
no: Number(item.item_no || item.no || 0),
name: String(item.name || ''),
product: String(item.product || ''),
quantity: Number(item.quantity || 0),
remark: String(item.remark || ''),
}));
// 장비 외 실행금액 변환
const externalCostData = apiData.external_equipment_cost as Record<string, unknown> | undefined;
const externalEquipmentCost: ExternalEquipmentCost = externalCostData
? {
shippingCost: Number(externalCostData.shipping_cost || externalCostData.shippingCost || 0),
highAltitudeWork: Number(externalCostData.high_altitude_work || externalCostData.highAltitudeWork || 0),
publicExpense: Number(externalCostData.public_expense || externalCostData.publicExpense || 0),
}
: {
shippingCost: 0,
highAltitudeWork: 0,
publicExpense: 0,
};
return {
id: String(apiData.id),
reportNumber: String(apiData.report_number || ''),
partnerName: String(apiData.partner_name || ''),
siteName: String(apiData.site_name || ''),
contractManagerName: String(apiData.contract_manager_name || ''),
constructionPMName: apiData.construction_pm_name ? String(apiData.construction_pm_name) : null,
constructionPMId: apiData.construction_pm_id ? String(apiData.construction_pm_id) : null,
totalSites: Number(apiData.total_sites || 0),
contractAmount: Number(apiData.contract_amount || 0),
contractDate: apiData.contract_date ? String(apiData.contract_date) : null,
contractStartDate: apiData.contract_start_date ? String(apiData.contract_start_date) : null,
contractEndDate: apiData.contract_end_date ? String(apiData.contract_end_date) : null,
completionDate: apiData.completion_date ? String(apiData.completion_date) : null,
status: (apiData.status as 'pending' | 'completed') || 'pending',
contractId: String(apiData.contract_id || ''),
createdAt: String(apiData.created_at || ''),
updatedAt: String(apiData.updated_at || ''),
constructionManagers,
contractItems,
hasSecondaryPiping: Boolean(apiData.has_secondary_piping),
secondaryPipingAmount: Number(apiData.secondary_piping_amount || 0),
secondaryPipingNote: String(apiData.secondary_piping_note || ''),
hasCoating: Boolean(apiData.has_coating),
coatingAmount: Number(apiData.coating_amount || 0),
coatingNote: String(apiData.coating_note || ''),
externalEquipmentCost,
specialNotes: String(apiData.special_notes || ''),
};
}
/**
* 프론트엔드 → 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 연동 함수
// ============================================================
interface GetHandoverReportListParams {
size?: number;
page?: number;
startDate?: string;
endDate?: string;
search?: string;
status?: string;
partnerId?: string;
contractManagerId?: string;
constructionPMId?: string;
sortBy?: string;
}
interface GetHandoverReportListResult {
@@ -132,33 +247,78 @@ interface GetHandoverReportListResult {
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 = new URLSearchParams();
if (params.search) queryParams.append('search', params.search);
if (params.status && params.status !== 'all') queryParams.append('status', params.status);
if (params.partnerId && params.partnerId !== 'all') queryParams.append('partner_id', params.partnerId);
if (params.contractManagerId && params.contractManagerId !== 'all') queryParams.append('contract_manager_id', params.contractManagerId);
if (params.constructionPMId && params.constructionPMId !== 'all') queryParams.append('construction_pm_id', params.constructionPMId);
if (params.startDate) queryParams.append('start_date', params.startDate);
if (params.endDate) queryParams.append('end_date', params.endDate);
if (params.page) queryParams.append('page', String(params.page));
if (params.size) queryParams.append('per_page', String(params.size));
// 정렬 파라미터 변환
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.append('sort_by', sort.field);
queryParams.append('sort_dir', sort.dir);
}
}
const queryString = queryParams.toString();
const endpoint = `/construction/handover-reports${queryString ? `?${queryString}` : ''}`;
const result = await apiRequest<{
data: Record<string, unknown>[];
current_page: number;
per_page: number;
total: number;
last_page: number;
}>(endpoint);
if (!result.success || !result.data) {
return { success: false, error: result.error || '인수인계보고서 목록 조회에 실패했습니다.' };
}
const apiData = result.data;
const items = (apiData.data || []).map(transformHandoverReport);
// 목업 데이터 반환
return {
success: true,
data: {
items: MOCK_REPORTS,
total: MOCK_REPORTS.length,
page: params.page || 1,
size: params.size || 20,
items,
total: apiData.total || 0,
page: apiData.current_page || 1,
size: apiData.per_page || 20,
totalPages: apiData.last_page || 1,
},
};
} catch (error) {
console.error('Failed to fetch handover report list:', error);
return {
success: false,
error: '인수인계보고서 목록을 불러오는데 실패했습니다.',
};
console.error('getHandoverReportList error:', error);
return { success: false, error: '인수인계보고서 목록을 불러오는데 실패했습니다.' };
}
}
@@ -168,28 +328,34 @@ interface GetHandoverReportStatsResult {
error?: string;
}
/**
* 인수인계보고서 통계 조회
*/
export async function getHandoverReportStats(): Promise<GetHandoverReportStatsResult> {
try {
// 실제 API 호출 시 여기에 구현
const result = await apiRequest<{
total_count: number;
pending_count: number;
completed_count: number;
total_amount?: number;
total_sites?: number;
}>('/construction/handover-reports/stats');
// 목업 통계 반환
const pending = MOCK_REPORTS.filter(r => r.status === 'pending').length;
const completed = MOCK_REPORTS.filter(r => r.status === 'completed').length;
if (!result.success || !result.data) {
return { success: false, error: result.error || '통계를 불러오는데 실패했습니다.' };
}
return {
success: true,
data: {
total: MOCK_REPORTS.length,
pending,
completed,
total: result.data.total_count || 0,
pending: result.data.pending_count || 0,
completed: result.data.completed_count || 0,
},
};
} catch (error) {
console.error('Failed to fetch handover report stats:', error);
return {
success: false,
error: '통계를 불러오는데 실패했습니다.',
};
console.error('getHandoverReportStats error:', error);
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
}
}
@@ -198,20 +364,23 @@ interface DeleteHandoverReportResult {
error?: string;
}
/**
* 인수인계보고서 삭제
*/
export async function deleteHandoverReport(id: string): Promise<DeleteHandoverReportResult> {
try {
// 실제 API 호출 시 여기에 구현
console.log('Deleting handover report:', id);
const result = await apiRequest(`/construction/handover-reports/${id}`, {
method: 'DELETE',
});
return {
success: true,
};
if (!result.success) {
return { success: false, error: result.error || '삭제에 실패했습니다.' };
}
return { success: true };
} catch (error) {
console.error('Failed to delete handover report:', error);
return {
success: false,
error: '삭제에 실패했습니다.',
};
console.error('deleteHandoverReport error:', error);
return { success: false, error: '삭제에 실패했습니다.' };
}
}
@@ -221,180 +390,110 @@ interface DeleteHandoverReportsResult {
error?: string;
}
/**
* 인수인계보고서 일괄 삭제
*/
export async function deleteHandoverReports(ids: string[]): Promise<DeleteHandoverReportsResult> {
try {
// 실제 API 호출 시 여기에 구현
console.log('Deleting handover reports:', ids);
const result = await apiRequest('/construction/handover-reports/bulk', {
method: 'DELETE',
body: JSON.stringify({ ids: ids.map((id) => Number(id)) }),
});
return {
success: true,
deletedCount: ids.length,
};
if (!result.success) {
return { success: false, error: result.error || '일괄 삭제에 실패했습니다.' };
}
return { success: true, deletedCount: ids.length };
} catch (error) {
console.error('Failed to delete handover reports:', error);
return {
success: false,
error: '일괄 삭제에 실패했습니다.',
};
console.error('deleteHandoverReports 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 {
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 result = await apiRequest<Record<string, unknown>>(`/construction/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: '인수인계보고서를 찾을 수 없습니다.',
};
if (!result.success || !result.data) {
return { success: false, error: result.error || '인수인계보고서를 찾을 수 없습니다.' };
}
return {
success: true,
data: detail,
};
return { success: true, data: transformHandoverReportDetail(result.data) };
} catch (error) {
console.error('Failed to fetch handover report detail:', error);
return {
success: false,
error: '인수인계보고서 상세 정보를 불러오는데 실패했습니다.',
};
console.error('getHandoverReportDetail error:', error);
return { success: false, error: '인수인계보고서 상세 정보를 불러오는데 실패했습니다.' };
}
}
interface UpdateHandoverReportResult {
success: boolean;
data?: HandoverReportDetail;
error?: string;
}
/**
* 인수인계보고서 수정
*/
export async function updateHandoverReport(
id: string,
data: HandoverReportFormData
): Promise<UpdateHandoverReportResult> {
try {
// 실제 API 호출 시 여기에 구현
console.log('Updating handover report:', id, data);
const apiData = transformToApiRequest(data);
return {
success: true,
};
const result = await apiRequest<Record<string, unknown>>(`/construction/handover-reports/${id}`, {
method: 'PUT',
body: JSON.stringify(apiData),
});
if (!result.success || !result.data) {
return { success: false, error: result.error || '수정에 실패했습니다.' };
}
return { success: true, data: transformHandoverReportDetail(result.data) };
} catch (error) {
console.error('Failed to update handover report:', error);
return {
success: false,
error: '수정에 실패했습니다.',
};
console.error('updateHandoverReport error:', error);
return { success: false, error: '수정에 실패했습니다.' };
}
}
interface CreateHandoverReportResult {
success: boolean;
data?: HandoverReportDetail;
error?: string;
}
/**
* 인수인계보고서 등록
*/
export async function createHandoverReport(
data: HandoverReportFormData
): Promise<CreateHandoverReportResult> {
try {
const apiData = transformToApiRequest(data);
const result = await apiRequest<Record<string, unknown>>('/construction/handover-reports', {
method: 'POST',
body: JSON.stringify(apiData),
});
if (!result.success || !result.data) {
return { success: false, error: result.error || '등록에 실패했습니다.' };
}
return { success: true, data: transformHandoverReportDetail(result.data) };
} catch (error) {
console.error('createHandoverReport error:', error);
return { success: false, error: '등록에 실패했습니다.' };
}
}