From 273d5709cdf73cca2694a8737c161278307cfecb Mon Sep 17 00:00:00 2001 From: kent Date: Fri, 9 Jan 2026 16:35:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=8B=9C=EA=B3=B5=EC=82=AC):=202.1=20?= =?UTF-8?q?=ED=98=84=EC=9E=A5=EA=B4=80=EB=A6=AC=20-=20Frontend=20API=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - actions.ts: Mock 데이터 → API 연동 - types.ts: SiteStats에 suspended, pending 추가 - 문서: API 연동 상세 문서 추가 --- ...-01-09] site-management-api-integration.md | 90 +++++ .../construction/site-management/actions.ts | 349 +++++++++++------- .../construction/site-management/types.ts | 4 +- 3 files changed, 308 insertions(+), 135 deletions(-) create mode 100644 claudedocs/construction/[IMPL-2026-01-09] site-management-api-integration.md diff --git a/claudedocs/construction/[IMPL-2026-01-09] site-management-api-integration.md b/claudedocs/construction/[IMPL-2026-01-09] site-management-api-integration.md new file mode 100644 index 00000000..307362d8 --- /dev/null +++ b/claudedocs/construction/[IMPL-2026-01-09] site-management-api-integration.md @@ -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 급여관리 \ No newline at end of file diff --git a/src/components/business/construction/site-management/actions.ts b/src/components/business/construction/site-management/actions.ts index edc3245b..b668854d 100644 --- a/src/components/business/construction/site-management/actions.ts +++ b/src/components/business/construction/site-management/actions.ts @@ -1,195 +1,276 @@ 'use server'; -import type { Site, SiteStats } from './types'; +import { cookies } from 'next/headers'; +import type { Site, SiteStats, SiteStatus } from './types'; -// 목업 현장 데이터 -const MOCK_SITES: Site[] = [ - { - id: '1', - siteCode: '123123', - partnerId: '1', - partnerName: '회사명', - siteName: '현장명', - address: '-', - status: 'unregistered', - createdAt: '2025-09-01T00:00:00Z', - updatedAt: '2025-09-01T00:00:00Z', - }, - { - id: '2', - siteCode: '123123', - partnerId: '1', - partnerName: '회사명', - siteName: '현장명', - address: '서울시 강남구 대현빌라 123길', - status: 'suspended', - createdAt: '2025-09-02T00:00:00Z', - updatedAt: '2025-09-02T00:00:00Z', - }, - { - id: '3', - siteCode: '123123', - partnerId: '2', - partnerName: '회사명', - siteName: '현장명', - address: '서울시 강남구 대현빌라 123길', - status: 'active', - createdAt: '2025-09-03T00:00:00Z', - updatedAt: '2025-09-03T00:00:00Z', - }, - { - id: '4', - siteCode: '123123', - partnerId: '1', - partnerName: '회사명', - siteName: '현장명', - address: '서울시 강남구 대현빌라 123길', - status: 'active', - createdAt: '2025-09-04T00:00:00Z', - updatedAt: '2025-09-04T00:00:00Z', - }, - { - id: '5', - siteCode: '123123', - partnerId: '3', - partnerName: '회사명', - siteName: '현장명', - address: '서울시 강남구 대현빌라 123길', - status: 'active', - createdAt: '2025-09-05T00:00:00Z', - updatedAt: '2025-09-05T00:00:00Z', - }, - { - id: '6', - siteCode: '123123', - partnerId: '1', - partnerName: '회사명', - siteName: '현장명', - address: '서울시 강남구 대현빌라 123길', - status: 'active', - createdAt: '2025-09-06T00:00:00Z', - updatedAt: '2025-09-06T00:00:00Z', - }, - { - id: '7', - siteCode: '123123', - partnerId: '2', - partnerName: '회사명', - siteName: '현장명', - address: '서울시 강남구 대현빌라 123길', - status: 'pending', - createdAt: '2025-09-07T00:00:00Z', - updatedAt: '2025-09-07T00:00:00Z', - }, -]; +/** + * 주일 기업 - 현장관리 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( + 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 = { + '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('🔵 [Site API]', options.method || 'GET', url); + + const response = await fetch(url, { + ...options, + headers: { + ...headers, + ...options.headers, + }, + }); + + const result = await response.json(); + console.log('🔵 [Site 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 transformSite(apiData: Record): Site { + // client 관계 데이터 추출 + const client = apiData.client as Record | null | undefined; + + return { + id: String(apiData.id), + siteCode: String(apiData.site_code || ''), + partnerId: apiData.client_id ? String(apiData.client_id) : '', + partnerName: client ? String(client.name || '') : '', + 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 { size?: number; + page?: number; startDate?: string; endDate?: string; + search?: string; + status?: string; + clientId?: string; + sortBy?: string; } interface GetSiteListResult { success: boolean; data?: { items: Site[]; - totalCount: number; + total: number; + page: number; + size: number; + totalPages: number; }; error?: string; } -// 현장 목록 조회 +/** + * 현장 목록 조회 + */ export async function getSiteList(params: GetSiteListParams = {}): Promise { try { - // TODO: API 연동 시 실제 API 호출로 변경 - await new Promise((resolve) => setTimeout(resolve, 500)); + const queryParams = new URLSearchParams(); - 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) { - filteredSites = filteredSites.filter( - (site) => new Date(site.createdAt) >= new Date(params.startDate!) - ); + // 정렬 파라미터 변환 + if (params.sortBy) { + const sortMap: Record = { + 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( - (site) => new Date(site.createdAt) <= new Date(params.endDate!) - ); + + const queryString = queryParams.toString(); + const endpoint = `/sites${queryString ? `?${queryString}` : ''}`; + + const result = await apiRequest<{ + data: Record[]; + 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 { success: true, data: { - items: filteredSites, - totalCount: filteredSites.length, + items, + total: apiData.total || 0, + page: apiData.current_page || 1, + size: apiData.per_page || 20, + totalPages: apiData.last_page || 1, }, }; } catch (error) { console.error('getSiteList error:', error); - return { - success: false, - error: '현장 목록을 불러오는데 실패했습니다.', - }; + return { success: false, error: '현장 목록을 불러오는데 실패했습니다.' }; } } -// 현장 통계 조회 -export async function getSiteStats(): Promise<{ success: boolean; data?: SiteStats; error?: string }> { - try { - // TODO: API 연동 시 실제 API 호출로 변경 - await new Promise((resolve) => setTimeout(resolve, 300)); +interface GetSiteStatsResult { + success: boolean; + data?: SiteStats; + error?: string; +} - 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 { + 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 { success: true, data: { - total, - construction, - unregistered, + total: result.data.total || 0, + construction: result.data.construction || 0, + unregistered: result.data.unregistered || 0, + suspended: result.data.suspended || 0, + pending: result.data.pending || 0, }, }; } catch (error) { console.error('getSiteStats error:', error); - return { - success: false, - error: '현장 통계를 불러오는데 실패했습니다.', - }; + return { success: false, error: '현장 통계를 불러오는데 실패했습니다.' }; } } -// 현장 삭제 -export async function deleteSite(id: string): Promise<{ success: boolean; error?: string }> { +interface DeleteSiteResult { + success: boolean; + error?: string; +} + +/** + * 현장 삭제 + */ +export async function deleteSite(id: string): Promise { try { - // TODO: API 연동 시 실제 API 호출로 변경 - await new Promise((resolve) => setTimeout(resolve, 500)); + const result = await apiRequest(`/sites/${id}`, { + method: 'DELETE', + }); + + if (!result.success) { + return { success: false, error: result.error || '현장 삭제에 실패했습니다.' }; + } + return { success: true }; } catch (error) { console.error('deleteSite error:', error); - return { - success: false, - error: '현장 삭제에 실패했습니다.', - }; + return { success: false, error: '현장 삭제에 실패했습니다.' }; } } -// 현장 일괄 삭제 -export async function deleteSites(ids: string[]): Promise<{ success: boolean; deletedCount?: number; error?: string }> { +interface DeleteSitesResult { + success: boolean; + deletedCount?: number; + error?: string; +} + +/** + * 현장 일괄 삭제 + */ +export async function deleteSites(ids: string[]): Promise { try { - // TODO: API 연동 시 실제 API 호출로 변경 - await new Promise((resolve) => setTimeout(resolve, 500)); + const result = await apiRequest<{ deleted_count: number }>('/sites/bulk', { + method: 'DELETE', + body: JSON.stringify({ ids: ids.map((id) => Number(id)) }), + }); + + if (!result.success) { + return { success: false, error: result.error || '현장 일괄 삭제에 실패했습니다.' }; + } + return { success: true, - deletedCount: ids.length, + deletedCount: result.data?.deleted_count || ids.length, }; } catch (error) { console.error('deleteSites error:', error); - return { - success: false, - error: '현장 일괄 삭제에 실패했습니다.', - }; + return { success: false, error: '현장 일괄 삭제에 실패했습니다.' }; } -} +} \ No newline at end of file diff --git a/src/components/business/construction/site-management/types.ts b/src/components/business/construction/site-management/types.ts index 85eaabbc..5051d47c 100644 --- a/src/components/business/construction/site-management/types.ts +++ b/src/components/business/construction/site-management/types.ts @@ -17,8 +17,10 @@ export type SiteStatus = 'unregistered' | 'suspended' | 'active' | 'pending'; // 현장 통계 export interface SiteStats { total: number; // 전체 현장 - construction: number; // 시공 현장 + construction: number; // 시공 현장 (active) unregistered: number; // 미등록 현장 + suspended: number; // 중지 현장 + pending: number; // 보류 현장 } // 상태 옵션