feat(시공사): 2.1 현장관리 - Frontend API 연동
- actions.ts: Mock 데이터 → API 연동 - types.ts: SiteStats에 suspended, pending 추가 - 문서: API 연동 상세 문서 추가
This commit is contained in:
@@ -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 급여관리
|
||||||
@@ -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: '현장 일괄 삭제에 실패했습니다.',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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; // 보류 현장
|
||||||
}
|
}
|
||||||
|
|
||||||
// 상태 옵션
|
// 상태 옵션
|
||||||
|
|||||||
Reference in New Issue
Block a user