feat(시공사): 2.1 현장관리 - Frontend API 연동

- actions.ts: Mock 데이터 → API 연동
- types.ts: SiteStats에 suspended, pending 추가
- 문서: API 연동 상세 문서 추가
This commit is contained in:
2026-01-09 16:35:12 +09:00
parent 78e193c8df
commit 273d5709cd
3 changed files with 308 additions and 135 deletions

View File

@@ -0,0 +1,90 @@
# Phase 2.1 현장관리 API 연동
**날짜**: 2026-01-09
**작업**: 현장관리 Mock → API 연동
## 개요
시공사 페이지 API 연동 계획 Phase 2.1 - 현장관리(site-management) API 연동 완료.
## 변경 사항
### Backend (API)
#### 1. 마이그레이션
- `2026_01_09_162534_add_construction_fields_to_sites_table.php`
- `site_code` (VARCHAR 50) - 현장코드
- `client_id` (FK → clients) - 거래처 연결
- `status` (ENUM) - unregistered/suspended/active/pending
- 인덱스: tenant_id + site_code, tenant_id + status
#### 2. 모델 (Site.php)
- 상태 상수 추가: STATUS_UNREGISTERED, STATUS_SUSPENDED, STATUS_ACTIVE, STATUS_PENDING
- fillable 확장: site_code, client_id, status
- Client 관계 추가
#### 3. 서비스 (SiteService.php)
- `index()` - 필터 확장 (status, client_id, start_date, end_date)
- `stats()` - 상태별 통계 조회 (신규)
- `bulkDestroy()` - 일괄 삭제 (신규)
#### 4. 컨트롤러 (SiteController.php)
- `stats()` - GET /api/v1/sites/stats
- `bulkDestroy()` - DELETE /api/v1/sites/bulk
#### 5. 라우트 (api.php)
```php
Route::get('/stats', [SiteController::class, 'stats']);
Route::delete('/bulk', [SiteController::class, 'bulkDestroy']);
```
### Frontend (React)
#### 1. types.ts
- SiteStats에 suspended, pending 필드 추가
#### 2. actions.ts
- Mock 데이터 제거
- API 연동 구현
- `getSiteList()` - GET /api/v1/sites
- `getSiteStats()` - GET /api/v1/sites/stats
- `deleteSite()` - DELETE /api/v1/sites/{id}
- `deleteSites()` - DELETE /api/v1/sites/bulk
## API 매핑
| Frontend | Backend | 비고 |
|----------|---------|------|
| id | id | string ↔ int |
| siteCode | site_code | |
| partnerId | client_id | |
| partnerName | client.name | 관계 eager load |
| siteName | name | |
| address | address | |
| status | status | 동일 |
| createdAt | created_at | |
| updatedAt | updated_at | |
## 설계 결정
### is_active vs status
- `is_active` (boolean): 사용 여부 (활성화/비활성화)
- `status` (enum): 상태값 (미등록/중지/사용/보류)
- 두 필드는 다른 용도로 둘 다 유지
### 기존 API 활용
- `/api/v1/sites` 기존 엔드포인트 확장 사용
- `/api/v1/construction/sites` 별도 생성하지 않음
## 진행률
시공사 API 연동: 3/9 (33%)
- [x] Phase 1.1 견적관리
- [x] Phase 1.2 인수인계보고서관리
- [x] Phase 2.1 현장관리 ← 현재 완료
- [ ] Phase 2.2 거래처관리
- [ ] Phase 2.3 자재관리
- [ ] Phase 3.1 발주관리
- [ ] Phase 3.2 재고관리
- [ ] Phase 4.1 정산관리
- [ ] Phase 4.2 급여관리

View File

@@ -1,195 +1,276 @@
'use server'; 'use server';
import type { Site, SiteStats } from './types'; import { cookies } from 'next/headers';
import type { Site, SiteStats, SiteStatus } from './types';
// 목업 현장 데이터 /**
const MOCK_SITES: Site[] = [ * 주일 기업 - 현장관리 Server Actions
{ * API 연동 버전
id: '1', */
siteCode: '123123',
partnerId: '1', // API 기본 URL
partnerName: '회사명', const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://api.sam.kr';
siteName: '현장명', const API_KEY = process.env.API_KEY || '';
address: '-',
status: 'unregistered', /**
createdAt: '2025-09-01T00:00:00Z', * API 요청 헬퍼 함수
updatedAt: '2025-09-01T00:00:00Z', */
}, async function apiRequest<T>(
{ endpoint: string,
id: '2', options: RequestInit = {}
siteCode: '123123', ): Promise<{ success: boolean; data?: T; error?: string; message?: string }> {
partnerId: '1', try {
partnerName: '회사명', const cookieStore = await cookies();
siteName: '현장명', const accessToken = cookieStore.get('access_token')?.value;
address: '서울시 강남구 대현빌라 123길',
status: 'suspended', const headers: Record<string, string> = {
createdAt: '2025-09-02T00:00:00Z', 'Accept': 'application/json',
updatedAt: '2025-09-02T00:00:00Z', 'Content-Type': 'application/json',
}, 'X-API-KEY': API_KEY,
{ };
id: '3',
siteCode: '123123', if (accessToken) {
partnerId: '2', headers['Authorization'] = `Bearer ${accessToken}`;
partnerName: '회사명', }
siteName: '현장명',
address: '서울시 강남구 대현빌라 123길', const url = `${API_BASE_URL}/api/v1${endpoint}`;
status: 'active', console.log('🔵 [Site API]', options.method || 'GET', url);
createdAt: '2025-09-03T00:00:00Z',
updatedAt: '2025-09-03T00:00:00Z', const response = await fetch(url, {
}, ...options,
{ headers: {
id: '4', ...headers,
siteCode: '123123', ...options.headers,
partnerId: '1', },
partnerName: '회사명', });
siteName: '현장명',
address: '서울시 강남구 대현빌라 123길', const result = await response.json();
status: 'active', console.log('🔵 [Site API] Response status:', response.status);
createdAt: '2025-09-04T00:00:00Z',
updatedAt: '2025-09-04T00:00:00Z', if (!response.ok) {
}, return {
{ success: false,
id: '5', error: result.message || `API 오류: ${response.status}`,
siteCode: '123123', };
partnerId: '3', }
partnerName: '회사명',
siteName: '현장명', return {
address: '서울시 강남구 대현빌라 123길', success: result.success ?? true,
status: 'active', data: result.data,
createdAt: '2025-09-05T00:00:00Z', message: result.message,
updatedAt: '2025-09-05T00:00:00Z', };
}, } catch (error) {
{ console.error('API request error:', error);
id: '6', return {
siteCode: '123123', success: false,
partnerId: '1', error: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
partnerName: '회사명', };
siteName: '현장명', }
address: '서울시 강남구 대현빌라 123길', }
status: 'active',
createdAt: '2025-09-06T00:00:00Z', /**
updatedAt: '2025-09-06T00:00:00Z', * API 응답 → 프론트엔드 타입 변환
}, */
{ function transformSite(apiData: Record<string, unknown>): Site {
id: '7', // client 관계 데이터 추출
siteCode: '123123', const client = apiData.client as Record<string, unknown> | null | undefined;
partnerId: '2',
partnerName: '회사명', return {
siteName: '현장명', id: String(apiData.id),
address: '서울시 강남구 대현빌라 123길', siteCode: String(apiData.site_code || ''),
status: 'pending', partnerId: apiData.client_id ? String(apiData.client_id) : '',
createdAt: '2025-09-07T00:00:00Z', partnerName: client ? String(client.name || '') : '',
updatedAt: '2025-09-07T00:00:00Z', siteName: String(apiData.name || ''),
}, address: String(apiData.address || ''),
]; status: (apiData.status as SiteStatus) || 'unregistered',
createdAt: String(apiData.created_at || ''),
updatedAt: String(apiData.updated_at || ''),
};
}
// ============================================================
// API 연동 함수
// ============================================================
interface GetSiteListParams { interface GetSiteListParams {
size?: number; size?: number;
page?: number;
startDate?: string; startDate?: string;
endDate?: string; endDate?: string;
search?: string;
status?: string;
clientId?: string;
sortBy?: string;
} }
interface GetSiteListResult { interface GetSiteListResult {
success: boolean; success: boolean;
data?: { data?: {
items: Site[]; items: Site[];
totalCount: number; total: number;
page: number;
size: number;
totalPages: number;
}; };
error?: string; error?: string;
} }
// 현장 목록 조회 /**
* 현장 목록 조회
*/
export async function getSiteList(params: GetSiteListParams = {}): Promise<GetSiteListResult> { export async function getSiteList(params: GetSiteListParams = {}): Promise<GetSiteListResult> {
try { try {
// TODO: API 연동 시 실제 API 호출로 변경 const queryParams = new URLSearchParams();
await new Promise((resolve) => setTimeout(resolve, 500));
let filteredSites = [...MOCK_SITES]; if (params.search) queryParams.append('search', params.search);
if (params.status && params.status !== 'all') queryParams.append('status', params.status);
if (params.clientId && params.clientId !== 'all') queryParams.append('client_id', params.clientId);
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.startDate) { if (params.sortBy) {
filteredSites = filteredSites.filter( const sortMap: Record<string, { field: string; dir: string }> = {
(site) => new Date(site.createdAt) >= new Date(params.startDate!) 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.append('sort_by', sort.field);
queryParams.append('sort_dir', sort.dir);
}
} }
if (params.endDate) {
filteredSites = filteredSites.filter( const queryString = queryParams.toString();
(site) => new Date(site.createdAt) <= new Date(params.endDate!) const endpoint = `/sites${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(transformSite);
return { return {
success: true, success: true,
data: { data: {
items: filteredSites, items,
totalCount: filteredSites.length, total: apiData.total || 0,
page: apiData.current_page || 1,
size: apiData.per_page || 20,
totalPages: apiData.last_page || 1,
}, },
}; };
} catch (error) { } catch (error) {
console.error('getSiteList error:', error); console.error('getSiteList error:', error);
return { return { success: false, error: '현장 목록을 불러오는데 실패했습니다.' };
success: false,
error: '현장 목록을 불러오는데 실패했습니다.',
};
} }
} }
// 현장 통계 조회 interface GetSiteStatsResult {
export async function getSiteStats(): Promise<{ success: boolean; data?: SiteStats; error?: string }> { success: boolean;
try { data?: SiteStats;
// TODO: API 연동 시 실제 API 호출로 변경 error?: string;
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; */
export async function getSiteStats(): Promise<GetSiteStatsResult> {
try {
const result = await apiRequest<{
total: number;
construction: number;
unregistered: number;
suspended: number;
pending: number;
}>('/sites/stats');
if (!result.success || !result.data) {
return { success: false, error: result.error || '현장 통계 조회에 실패했습니다.' };
}
return { return {
success: true, success: true,
data: { data: {
total, total: result.data.total || 0,
construction, construction: result.data.construction || 0,
unregistered, unregistered: result.data.unregistered || 0,
suspended: result.data.suspended || 0,
pending: result.data.pending || 0,
}, },
}; };
} catch (error) { } catch (error) {
console.error('getSiteStats error:', error); console.error('getSiteStats error:', error);
return { return { success: false, error: '현장 통계를 불러오는데 실패했습니다.' };
success: false,
error: '현장 통계를 불러오는데 실패했습니다.',
};
} }
} }
// 현장 삭제 interface DeleteSiteResult {
export async function deleteSite(id: string): Promise<{ success: boolean; error?: string }> { success: boolean;
error?: string;
}
/**
* 현장 삭제
*/
export async function deleteSite(id: string): Promise<DeleteSiteResult> {
try { try {
// TODO: API 연동 시 실제 API 호출로 변경 const result = await apiRequest(`/sites/${id}`, {
await new Promise((resolve) => setTimeout(resolve, 500)); method: 'DELETE',
});
if (!result.success) {
return { success: false, error: result.error || '현장 삭제에 실패했습니다.' };
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('deleteSite error:', error); console.error('deleteSite error:', error);
return { return { success: false, error: '현장 삭제에 실패했습니다.' };
success: false,
error: '현장 삭제에 실패했습니다.',
};
} }
} }
// 현장 일괄 삭제 interface DeleteSitesResult {
export async function deleteSites(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> { success: boolean;
deletedCount?: number;
error?: string;
}
/**
* 현장 일괄 삭제
*/
export async function deleteSites(ids: string[]): Promise<DeleteSitesResult> {
try { try {
// TODO: API 연동 시 실제 API 호출로 변경 const result = await apiRequest<{ deleted_count: number }>('/sites/bulk', {
await new Promise((resolve) => setTimeout(resolve, 500)); method: 'DELETE',
body: JSON.stringify({ ids: ids.map((id) => Number(id)) }),
});
if (!result.success) {
return { success: false, error: result.error || '현장 일괄 삭제에 실패했습니다.' };
}
return { return {
success: true, success: true,
deletedCount: ids.length, deletedCount: result.data?.deleted_count || ids.length,
}; };
} catch (error) { } catch (error) {
console.error('deleteSites error:', error); console.error('deleteSites error:', error);
return { return { success: false, error: '현장 일괄 삭제에 실패했습니다.' };
success: false,
error: '현장 일괄 삭제에 실패했습니다.',
};
} }
} }

View File

@@ -17,8 +17,10 @@ export type SiteStatus = 'unregistered' | 'suspended' | 'active' | 'pending';
// 현장 통계 // 현장 통계
export interface SiteStats { export interface SiteStats {
total: number; // 전체 현장 total: number; // 전체 현장
construction: number; // 시공 현장 construction: number; // 시공 현장 (active)
unregistered: number; // 미등록 현장 unregistered: number; // 미등록 현장
suspended: number; // 중지 현장
pending: number; // 보류 현장
} }
// 상태 옵션 // 상태 옵션