diff --git a/claudedocs/[TASK-2026-03-03] daily-report-usd-section.md b/claudedocs/[TASK-2026-03-03] daily-report-usd-section.md new file mode 100644 index 00000000..6df8f060 --- /dev/null +++ b/claudedocs/[TASK-2026-03-03] daily-report-usd-section.md @@ -0,0 +1,172 @@ +# 일일일보 — USD(외국환) 섹션 누락 + +**유형**: 프론트엔드 UI 누락 +**파일**: `src/components/accounting/DailyReport/index.tsx` +**날짜**: 2026-03-03 + +--- + +## 현상 + +일일일보 페이지에 KRW(원화) 계좌만 표시되고, USD(외국환) 계좌 섹션이 없음. +summary에 `usd_totals`(이월/입금/출금/잔액)이 내려오고, daily-accounts에 `currency: 'USD'` 항목도 내려오지만 UI에서 렌더링하지 않음. + +--- + +## 원인 + +모든 테이블에서 `currency === 'KRW'` 필터만 적용 중: + +```tsx +// line 391 — 계좌별 상세 +filteredDailyAccounts.filter(item => item.currency === 'KRW') + +// line 448 — 입금 테이블 +filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0) + +// line 497 — 출금 테이블 +filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0) +``` + +--- + +## 요구사항 + +기존 KRW 섹션과 동일한 구조로 USD 섹션 추가: + +### 1. 일자별 상세 테이블에 USD 행 추가 +- 기존 KRW 계좌 목록 아래에 USD 계좌 목록 표시 +- 또는 KRW/USD 구분 소계 행으로 분리 +- 합계: `accountTotals.usd` 사용 (이미 계산 로직 있음, line 134-144) + +### 2. 예금 입출금 내역에 USD 입금/출금 테이블 추가 +- 기존 KRW 입금/출금 아래에 USD 입금/출금 테이블 추가 +- 필터: `currency === 'USD' && item.income > 0` / `currency === 'USD' && item.expense > 0` +- 금액 표시: USD 포맷 ($ 또는 달러 표기) + +--- + +## 참고: 이미 준비된 데이터 + +### summary에서 내려오는 USD 데이터 (line 53-58) +```typescript +summary: { + krwTotals: { carryover, income, expense, balance }, // ← 현재 사용 중 + usdTotals: { carryover, income, expense, balance }, // ← 미사용 (여기 추가) +} +``` + +### accountTotals 계산 로직 (line 134-144) +```typescript +// 이미 USD 합계 계산이 있음 — 사용만 하면 됨 +const usdAccounts = dailyAccounts.filter(item => item.currency === 'USD'); +const usdTotal = usdAccounts.reduce( + (acc, item) => ({ + carryover: acc.carryover + item.carryover, + income: acc.income + item.income, + expense: acc.expense + item.expense, + balance: acc.balance + item.balance, + }), + { carryover: 0, income: 0, expense: 0, balance: 0 } +); +// accountTotals.usd 로 접근 가능 +``` + +--- + +## 작업 범위 + +| 작업 | 설명 | +|------|------| +| 일자별 상세 테이블 | USD 계좌 행 추가 + USD 소계 행 | +| 입금 테이블 | USD 입금 내역 추가 | +| 출금 테이블 | USD 출금 내역 추가 | +| 금액 포맷 | USD 표시 (달러 기호 또는 통화 표기) | + +**수정 파일**: `src/components/accounting/DailyReport/index.tsx` (이 파일만) +**새 코드 불필요**: API 데이터, 타입, 계산 로직 모두 이미 있음. 렌더링만 추가. + +**상태**: ✅ 완료 (프론트엔드 렌더링 추가됨) + +--- + +# CEO 대시보드 — 자금현황 데이터 정합성 이슈 + +**유형**: 백엔드 데이터 불일치 +**관련 API**: `GET /api/proxy/daily-report/summary` +**관련 파일**: `sam-api/app/Services/DailyReportService.php` +**날짜**: 2026-03-03 + +--- + +## 현상 + +CEO 대시보드 자금현황 섹션의 **입금 합계**가 입금 관리 페이지(`/accounting/deposits`)의 실제 데이터와 불일치. + +| 항목 | 대시보드 summary API | 입금 관리 페이지 API | 차이 | +|------|---------------------|---------------------|------| +| 3월 입금 합계 | **200,000원** | **50,000원** (1건) | **150,000원 차이** | +| 3월 출금 합계 | 50,000원 | 50,000원 (1건) | 일치 | + +--- + +## 자금현황 각 수치의 의미 (현재 구조) + +``` +현금성 자산 합계 (cash_asset_total) += KRW 활성 계좌들의 누적 잔액 합계 (당월만이 아닌 전체 잔고) +├── 전월이월(carryover): 49,872,638원 ← 3월 이전 누적 (입금총액 - 출금총액) +├── 당월입금(income): 200,000원 ← 3월 1일~오늘 입금 +├── 당월출금(expense): 50,000원 ← 3월 1일~오늘 출금 +└── 잔액(balance): 50,022,638원 = 이월+입금-출금 + +외국환(USD) 합계 (foreign_currency_total) = USD 계좌 잔액 합계 +입금 합계 = krw_totals.income (당월 KRW 입금만) +출금 합계 = krw_totals.expense (당월 KRW 출금만) +``` + +--- + +## 원인 분석 + +### 대시보드 summary API 쿼리 (DailyReportService.php line 77-80) +```php +$income = Deposit::where('tenant_id', $tenantId) + ->where('bank_account_id', $account->id) + ->whereBetween('deposit_date', [$startOfMonth, $endOfDay]) + ->sum('amount'); +``` + +### 입금 관리 페이지 API 쿼리 +- 별도 컨트롤러/서비스에서 조회 +- 동일한 `deposits` 테이블을 읽지만, 조회 조건이 다를 수 있음 + +### 불일치 가능 원인 +1. **soft delete 차이**: summary는 soft-deleted 레코드 포함, 목록 API는 제외 +2. **tenant_id 조건 차이**: 두 API의 tenant 필터링이 다를 수 있음 +3. **E2E 테스트 데이터**: 테스트가 DB에 직접 삽입한 레코드가 목록 API에서는 필터됨 +4. **status 필터**: 입금 관리 목록에 status 조건이 추가되어 일부 제외 + +--- + +## 확인 필요 사항 (백엔드) + +### 1. deposits 테이블 직접 조회 +```sql +SELECT id, deposit_date, amount, bank_account_id, deleted_at, status +FROM deposits +WHERE tenant_id = [현재테넌트] + AND bank_account_id = 1 + AND deposit_date BETWEEN '2026-03-01' AND '2026-03-03' +ORDER BY id; +``` +→ 실제 레코드 수와 합계 확인 (soft delete, status 포함) + +### 2. 두 API의 쿼리 조건 비교 +- `DailyReportService::dailyAccounts()` — Deposit 모델 조건 +- 입금 관리 컨트롤러/서비스 — Deposit 모델 조건 +- 차이점 확인 (withTrashed, status 등) + +### 3. 해결 방향 +- 두 API가 동일한 데이터 소스를 보도록 통일 +- 또는 대시보드에서 기존 입금/출금 관리 API를 재사용하여 데이터 일관성 확보 diff --git a/claudedocs/api/[API-REQ-2026-03-03] expected-expenses-dashboard-detail-date-filter.md b/claudedocs/api/[API-REQ-2026-03-03] expected-expenses-dashboard-detail-date-filter.md new file mode 100644 index 00000000..1aeab278 --- /dev/null +++ b/claudedocs/api/[API-REQ-2026-03-03] expected-expenses-dashboard-detail-date-filter.md @@ -0,0 +1,52 @@ +# 백엔드 API 수정 요청: 당월 예상 지출 상세 - 날짜 범위 필터링 + +## 엔드포인트 +`GET /api/v1/expected-expenses/dashboard-detail` + +## 현재 상태 +- `transaction_type` 파라미터만 지원 (purchase, card, bill) +- `start_date`, `end_date` 파라미터를 **무시**함 +- `items` 배열이 항상 **당월(현재 월)** 기준으로만 반환됨 +- `summary`도 당월 기준 고정 (total_amount, change_rate 등) +- `monthly_trend`만 여러 월 데이터 포함 (최근 7개월) + +## 요청 내용 + +### 1. 날짜 범위 필터 지원 추가 +``` +GET /api/v1/expected-expenses/dashboard-detail?transaction_type=purchase&start_date=2026-01-01&end_date=2026-01-31 +``` + +| 파라미터 | 타입 | 설명 | 기본값 | +|---------|------|------|--------| +| `start_date` | string (yyyy-MM-dd) | 조회 시작일 | 당월 1일 | +| `end_date` | string (yyyy-MM-dd) | 조회 종료일 | 당월 말일 | +| `search` | string | 거래처/항목 검색 | (없음) | + +### 2. 기대 동작 +- `items`: `start_date` ~ `end_date` 범위의 거래 내역만 반환 +- `summary.total_amount`: 해당 기간의 합계 +- `summary.change_rate`: 해당 기간 vs 직전 동일 기간 비교 +- `vendor_distribution`: 해당 기간 기준 분포 +- `footer_summary`: 해당 기간 기준 합계 +- `monthly_trend`: 변경 불필요 (기존처럼 최근 7개월 유지) + +### 3. 검색 필터 (선택) +- `search` 파라미터로 거래처명/항목명 부분 검색 + +## 검증 데이터 +현재 `monthly_trend` 기준 데이터가 있는 월: +- 11월: 14,101,865원 +- 12월: 35,241,935원 +- 1월: 3,000,000원 +- 2월: 1,650,000원 + +`start_date=2026-01-01&end_date=2026-01-31` 조회 시: +- `items`: 1월 거래 내역 (현재 빈 배열) +- `summary.total_amount`: 3,000,000 (현재 0) + +## 프론트엔드 준비 상태 +- 프록시: 쿼리 파라미터 정상 전달 확인 +- 훅: `fetchData(cardId, { startDate, endDate, search })` 지원 +- 모달: 조회 버튼 + 날짜 필터 UI 완료 +- 백엔드 수정만 되면 즉시 동작 diff --git a/claudedocs/api/[API-SPEC-2026-03-03] ceo-dashboard-backend-api.md b/claudedocs/api/[API-SPEC-2026-03-03] ceo-dashboard-backend-api.md new file mode 100644 index 00000000..44b2d5de --- /dev/null +++ b/claudedocs/api/[API-SPEC-2026-03-03] ceo-dashboard-backend-api.md @@ -0,0 +1,821 @@ +# CEO Dashboard 백엔드 API 명세서 + +**작성일**: 2026-03-03 +**기획서**: SAM_ERP_Storyboard_D1.7_260227.pdf p33~60 +**프론트엔드 타입**: `src/lib/api/dashboard/types.ts` +**대상**: 백엔드 팀 (Laravel sam-api) + +--- + +## 공통 규칙 + +### 응답 형식 +```json +{ + "success": true, + "message": "조회 성공", + "data": { ... } +} +``` + +### 인증 +- 모든 API는 `Authorization: Bearer {access_token}` 필수 +- Next.js API route 프록시(`/api/proxy/...`) 경유 + +### 캐싱 +- `sam_stat` 테이블 5분 캐시 (기존 구현 유지) +- 대시보드 API는 실시간성보다 성능 우선 + +### 날짜/기간 파라미터 규칙 +- 날짜: `YYYY-MM-DD` (예: `2026-03-03`) +- 월: `YYYY-MM` (예: `2026-03`) +- 분기: `year=2026&quarter=1` +- 기본값: 파라미터 미지정 시 **당월/당분기** 기준 + +--- + +## 검수 중 발견된 누락 API + +### N1. 오늘의 이슈 — 과거 이력 저장 및 조회 +**우선순위**: 상 +**페이지**: p34 +**현상**: `GET /api/v1/today-issues/summary?date=2026-02-17` 호출 시 항상 `{"items":[], "total_count":0}` 반환. 과거 이슈를 저장하는 구조가 없어서 이전 이슈 탭이 항상 빈 목록. + +**요구사항**: +1. **이슈 이력 테이블** 필요 (예: `dashboard_issue_history`) + - 매일 자정(또는 배치) 시점에 당일 이슈 스냅샷 저장 + - 또는 이슈 발생 시점에 이력 테이블에 INSERT +2. **기존 API 수정**: `GET /api/v1/today-issues/summary` + - `date` 파라미터가 있을 때 해당 날짜의 이력 데이터 반환 + - `date` 파라미터가 없으면 기존대로 실시간 집계 + +**Response** (기존 `TodayIssueApiResponse`와 동일): +```json +{ + "items": [ + { + "id": "issue-20260302-001", + "badge": "수주", + "notification_type": "sales_order", + "content": "대한건설 수주 3건 접수", + "time": "14:30", + "date": "2026-03-02", + "path": "/ko/sales/order-management", + "needs_approval": false + } + ], + "total_count": 5 +} +``` + +**Laravel 힌트**: +- 배치 저장 방식: `App\Console\Commands\SnapshotDailyIssues` (Schedule::daily) +- 또는 이벤트 기반: 수주/채권/재고 변동 시 `dashboard_issue_history` INSERT + +### N2. 자금현황 — 전일 대비 변동률 (daily_change) +**우선순위**: 중 +**페이지**: p33 +**현상**: `GET /api/v1/daily-report/summary` 응답에 `daily_change` 필드가 없음. 프론트엔드에서 하드코딩 fallback 값(+5.2%, +2.1%, +12.0%, -8.0%)을 사용 중. + +**요구사항**: +1. **기존 API 수정**: `GET /api/v1/daily-report/summary` +2. 응답에 `daily_change` 객체 추가 +3. 각 항목의 전일 대비 변동률(%) 계산 로직: + - `cash_asset_change_rate`: (오늘 현금성자산 - 어제 현금성자산) / 어제 현금성자산 × 100 + - `foreign_currency_change_rate`: (오늘 외국환 - 어제 외국환) / 어제 외국환 × 100 + - `income_change_rate`: (오늘 입금 - 어제 입금) / 어제 입금 × 100 + - `expense_change_rate`: (오늘 지출 - 어제 지출) / 어제 지출 × 100 +4. 어제 데이터 없을 시 해당 필드 `null` (프론트에서 fallback 처리) + +**Response** (기존 응답에 `daily_change` 추가): +```json +{ + "date": "2026-03-03", + "day_of_week": "화", + "cash_asset_total": 1250000000, + "foreign_currency_total": 85000, + "krw_totals": { "income": 45000000, "expense": 32000000, "balance": 1250000000 }, + "daily_change": { + "cash_asset_change_rate": 5.2, + "foreign_currency_change_rate": 2.1, + "income_change_rate": 12.0, + "expense_change_rate": -8.0 + } +} +``` + +**Laravel 힌트**: +- `DailyReportService`에서 전일 데이터 조회 추가 +- `sam_stat` 캐시 테이블에 전일 스냅샷 있으면 활용 +- 프론트 타입: `DailyChangeRate` (`src/lib/api/dashboard/types.ts:23`) + +### N3. 일일일보 — daily-accounts에 입출금관리 데이터 미반영 +**우선순위**: 상 +**페이지**: 일일일보 페이지 (`/ko/accounting/daily-report`) +**현상**: 입금관리/출금관리에서 당일 거래를 등록하면 대시보드 자금현황(`daily-report/summary`)의 합계에는 즉시 반영되지만, 일일일보 페이지의 계좌별 상세 테이블(`daily-report/daily-accounts`)에는 표시되지 않음. (출금 테스트로 확인됨, 입금도 동일 구조로 미반영 추정) + +**영향 범위**: +| 데이터 | 관리 테이블 | summary (합계) | daily-accounts (상세) | +|--------|-----------|:-:|:-:| +| 입금 | `deposits` (`/api/v1/deposits`) | ✅ 반영 추정 | ❌ 미반영 추정 | +| 출금 | `withdrawals` (`/api/v1/withdrawals`) | ✅ 반영 확인 | ❌ 미반영 확인 | +| 외국환 (USD) | 별도 관리 페이지 미확인 | ✅ 반영 | ❓ 확인 필요 | + +**원인 분석**: +- `GET /api/v1/daily-report/summary` → `krw_totals`에 `deposits`/`withdrawals` 테이블 데이터 포함 ✅ +- `GET /api/v1/daily-report/daily-accounts` → `bank_accounts` 단위 집계만 반환, `deposits`/`withdrawals` 테이블 미포함 ❌ + +**데이터 흐름**: +``` +입금관리 등록 → deposits 테이블 INSERT (bank_account_id 포함) +출금관리 등록 → withdrawals 테이블 INSERT (bank_account_id 포함) + ├─ summary API → krw_totals.income/expense에 합산 → 대시보드 ✅ + └─ daily-accounts API → bank_accounts 기준만 조회 → 일일일보 상세 ❌ +``` + +**요구사항**: +1. `GET /api/v1/daily-report/daily-accounts` 수정 +2. 각 계좌별로 `deposits` 테이블의 당일 income과 `withdrawals` 테이블의 당일 expense를 합산 +3. 또는 입금/출금 등록 시 해당 계좌의 거래 내역(`bank_account_transactions`)에도 자동 반영 + +**해결 방안 (택 1)**: +- **방안 A** (daily-accounts 쿼리 수정): `bank_accounts` LEFT JOIN `deposits`/`withdrawals` WHERE date = 당일 → 계좌별 income/expense에 합산 +- **방안 B** (트랜잭션 연동): 입금/출금 등록 시 `bank_account_transactions`에도 INSERT → daily-accounts가 자연스럽게 포함 + +**Response** (기존 `DailyAccountItemApi[]`와 동일, 데이터만 보완): +```json +[ + { + "id": "acc_1", + "category": "우리은행 123-456", + "match_status": "matched", + "carryover": 50000000, + "income": 1000000, + "expense": 50000, + "balance": 50950000, + "currency": "KRW" + } +] +``` + +**Laravel 힌트**: +- `DailyReportService`의 `getDailyAccounts()` 메서드 확인 +- `deposits` 테이블: `deposit.bank_account_id`로 해당 계좌 income 합산 +- `withdrawals` 테이블: `withdrawal.bank_account_id`로 해당 계좌 expense 합산 +- USD 계좌도 동일 패턴 적용 필요 + +### N4. 현황판 `purchases`(발주) — path 오류 + 데이터 정합성 이슈 +**우선순위**: 중 +**페이지**: p34 (현황판) + +#### 이슈 A: path 하드코딩 오류 +**현상**: `purchases` 항목의 실제 데이터는 `purchases` 테이블(매입, 공통)에서 조회하면서, path는 건설 모듈 경로로 하드코딩되어 있음. + +**문제 코드** (`StatusBoardService.php` — `getPurchaseStatus()`): +```php +$count = Purchase::query() + ->where('tenant_id', $tenantId) + ->where('status', 'draft') + ->count(); + +return [ + 'id' => 'purchases', + 'label' => '발주', + 'path' => '/construction/order/order-management', // ← 매입 데이터인데 건설 경로 +]; +``` + +- 데이터 출처: `purchases` 테이블 (모든 테넌트 공통 매입 테이블) +- path: `/construction/order/order-management` (건설 전용 페이지) +- **데이터와 path가 불일치** — 매입 draft 건수를 보여주면서 건설 발주 페이지로 링크 + +**현재 프론트 임시 대응**: `status-issue.ts`에서 `/accounting/purchase`(매입관리)로 오버라이드 중 + +**요구사항**: +1. path를 `/accounting/purchase`로 변경 (데이터 출처와 일치시키기) +2. 또는 테넌트 업종에 따라 path 동적 분기 (건설: `/construction/order/order-management`, 기타: `/accounting/purchase`) +3. 라벨도 재검토: "발주"가 맞는지, "매입(임시저장)"이 더 정확한지 + +#### 이슈 B: 데이터 정합성 의심 +**현상**: StatusBoard API에서 `purchases` count=**9건** 반환, 하지만 매입관리 페이지(`/accounting/purchase`)에서 전체 조회 시 **1건**만 표시. + +**확인 사항** (DB 직접 확인 필요): +```sql +-- 현재 테넌트의 purchases 테이블 전체 건수 +SELECT COUNT(*), status FROM purchases WHERE tenant_id = {현재 테넌트 ID} GROUP BY status; + +-- draft 상태 건수 (StatusBoard가 조회하는 조건) +SELECT COUNT(*) FROM purchases WHERE tenant_id = {현재 테넌트 ID} AND status = 'draft'; +``` + +**가능한 원인**: +1. StatusBoard와 매입관리 페이지가 다른 tenant_id 스코프로 조회 +2. DummyDataSeeder가 다른 tenant_id로 데이터 생성 +3. 매입관리 API에 추가 필터 조건이 있어서 draft 건이 제외됨 +4. StatusBoard가 실제와 다른 데이터를 집계 + +**기대 결과**: StatusBoard 9건 클릭 → 매입관리 페이지에서 9건 확인 가능해야 함 + +--- + +## 신규 API (10개) + +### 1. 매출 현황 Summary +**우선순위**: 중 +**페이지**: p39 + +``` +GET /api/v1/dashboard/sales/summary +``` + +**Query Params**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| year | int | N | 조회 연도 (기본: 당해) | +| month | int | N | 조회 월 (기본: 당월) | + +**Response** (`SalesStatusApiResponse`): +```json +{ + "cumulative_sales": 312300000, + "achievement_rate": 94.5, + "yoy_change": 12.5, + "monthly_sales": 312300000, + "monthly_trend": [ + { "month": "2026-08", "label": "8월", "amount": 250000000 }, + { "month": "2026-09", "label": "9월", "amount": 280000000 } + ], + "client_sales": [ + { "name": "대한건설", "amount": 95000000 }, + { "name": "삼성테크", "amount": 78000000 } + ], + "daily_items": [ + { + "date": "2026-02-01", + "client": "대한건설", + "item": "스크린 외", + "amount": 25000000, + "status": "deposited" + } + ], + "daily_total": 312300000 +} +``` + +**Laravel 힌트**: +- 매출: `sales_orders` 합계 (confirmed 상태) +- 달성률: 매출 목표 대비 (`sales_targets` 테이블) +- YoY: 전년 동월 대비 변화율 +- 거래처별: GROUP BY vendor_id → TOP 5 +- status 코드: `deposited` (입금완료), `unpaid` (미입금), `partial` (부분입금) + +--- + +### 2. 매입 현황 Summary +**우선순위**: 중 +**페이지**: p40 + +``` +GET /api/v1/dashboard/purchases/summary +``` + +**Query Params**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| year | int | N | 조회 연도 (기본: 당해) | +| month | int | N | 조회 월 (기본: 당월) | + +**Response** (`PurchaseStatusApiResponse`): +```json +{ + "cumulative_purchase": 312300000, + "unpaid_amount": 312300000, + "yoy_change": -12.5, + "monthly_trend": [ + { "month": "2026-08", "label": "8월", "amount": 180000000 } + ], + "material_ratio": [ + { "name": "원자재", "value": 55, "percentage": 55, "color": "#3b82f6" }, + { "name": "부자재", "value": 35, "percentage": 35, "color": "#10b981" }, + { "name": "소모품", "value": 10, "percentage": 10, "color": "#f59e0b" } + ], + "daily_items": [ + { + "date": "2026-02-01", + "supplier": "한국철강", + "item": "철판 외", + "amount": 45000000, + "status": "paid" + } + ], + "daily_total": 312300000 +} +``` + +**Laravel 힌트**: +- 매입: `purchase_orders` 합계 +- 미결제: 결제 미완료 건 합계 +- 원자재/부자재/소모품: `item_categories` 기준 분류 +- status 코드: `paid` (결제완료), `unpaid` (미결제), `partial` (부분결제) + +--- + +### 3. 생산 현황 Summary +**우선순위**: 상 +**페이지**: p41 + +``` +GET /api/v1/dashboard/production/summary +``` + +**Query Params**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) | + +**Response** (`DailyProductionApiResponse`): +```json +{ + "date": "2026-02-23", + "day_of_week": "월요일", + "processes": [ + { + "process_name": "스크린", + "total_work": 10, + "todo": 3, + "in_progress": 4, + "completed": 3, + "urgent": 2, + "sub_line": 1, + "regular": 5, + "worker_count": 8, + "work_items": [ + { + "id": "wo_1", + "order_no": "SO-2026-001", + "client": "대한건설", + "product": "스크린 A형", + "quantity": 50, + "status": "in_progress" + } + ], + "workers": [ + { + "name": "김철수", + "assigned": 5, + "completed": 3, + "rate": 60 + } + ] + } + ], + "shipment": { + "expected_amount": 150000000, + "expected_count": 12, + "actual_amount": 120000000, + "actual_count": 9 + } +} +``` + +**Laravel 힌트**: +- 공정: `work_processes` 테이블 (스크린, 슬랫, 절곡 등) +- 작업: `work_orders` JOIN `work_process_id` +- status: `pending` → todo, `in_progress`, `completed` +- urgent: 납기 3일 이내 +- 출고: `shipments` 테이블 (당일 예상 vs 실적) + +--- + +### 4. 출고 현황 (생산 현황에 포함) +**우선순위**: 하 +**페이지**: p41 + +생산 현황 API의 `shipment` 필드로 포함됨. 별도 API 불필요. + +--- + +### 5. 미출고 내역 +**우선순위**: 하 +**페이지**: p42 + +``` +GET /api/v1/dashboard/unshipped/summary +``` + +**Query Params**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| days | int | N | 납기 N일 이내 (기본: 30) | + +**Response** (`UnshippedApiResponse`): +```json +{ + "items": [ + { + "id": "us_1", + "port_no": "P-2026-001", + "site_name": "강남 현장", + "order_client": "대한건설", + "due_date": "2026-02-25", + "days_left": 2 + } + ], + "total_count": 7 +} +``` + +**Laravel 힌트**: +- `shipment_items` WHERE shipped_at IS NULL AND due_date >= NOW() +- days_left: DATEDIFF(due_date, NOW()) +- ORDER BY due_date ASC (납기 임박 순) + +--- + +### 6. 시공 현황 +**우선순위**: 중 +**페이지**: p42 + +``` +GET /api/v1/dashboard/construction/summary +``` + +**Query Params**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| month | int | N | 조회 월 (기본: 당월) | + +**Response** (`ConstructionApiResponse`): +```json +{ + "this_month": 15, + "completed": 5, + "items": [ + { + "id": "cs_1", + "site_name": "강남 현장", + "client": "대한건설", + "start_date": "2026-02-01", + "end_date": "2026-02-28", + "progress": 85, + "status": "in_progress" + } + ] +} +``` + +**Laravel 힌트**: +- `constructions` 테이블 +- status: `in_progress`, `scheduled`, `completed` +- completed: 최근 7일 이내 완료 건 + +--- + +### 7. 근태 현황 +**우선순위**: 중 +**페이지**: p43 + +``` +GET /api/v1/dashboard/attendance/summary +``` + +**Query Params**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) | + +**Response** (`DailyAttendanceApiResponse`): +```json +{ + "present": 42, + "on_leave": 3, + "late": 1, + "absent": 0, + "employees": [ + { + "id": "emp_1", + "department": "생산부", + "position": "과장", + "name": "김철수", + "status": "present" + } + ] +} +``` + +**Laravel 힌트**: +- `attendances` WHERE date = :date +- status: `present`, `on_leave`, `late`, `absent` +- employees: 이상 상태(late, absent, on_leave) 위주 표시 + +--- + +### 8. 일별 매출 내역 +**우선순위**: 하 +**페이지**: p47 (설정 팝업에서 별도 ON/OFF) + +매출 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시: + +``` +GET /api/v1/dashboard/sales/daily +``` + +**Query Params**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| start_date | string | N | 시작일 (기본: 당월 1일) | +| end_date | string | N | 종료일 (기본: 오늘) | +| page | int | N | 페이지 (기본: 1) | +| per_page | int | N | 건수 (기본: 20) | + +--- + +### 9. 일별 매입 내역 +**우선순위**: 하 + +매입 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시: + +``` +GET /api/v1/dashboard/purchases/daily +``` + +(매출 일별과 동일 구조) + +--- + +### 10. 접대비 상세 +**우선순위**: 상 +**페이지**: p53-54 + +``` +GET /api/v1/dashboard/entertainment/detail +``` + +**Query Params**: +| 파라미터 | 타입 | 필수 | 설명 | +|---------|------|------|------| +| year | int | N | 연도 | +| quarter | int | N | 분기 (1-4) | +| limit_type | string | N | annual/quarterly | +| company_type | string | N | large/medium/small | + +**Response**: +```json +{ + "summary": { + "total_used": 10000000, + "annual_limit": 40120000, + "remaining": 30120000, + "usage_rate": 24.9 + }, + "limit_calculation": { + "base_limit": 36000000, + "revenue_additional": 4120000, + "total_limit": 40120000, + "revenue": 2060000000, + "company_type": "medium" + }, + "quarterly_status": [ + { + "quarter": 1, + "label": "1분기", + "limit": 10030000, + "used": 3500000, + "remaining": 6530000, + "exceeded": 0 + } + ], + "transactions": [ + { + "id": 1, + "date": "2026-01-15", + "user_name": "홍길동", + "merchant_name": "강남식당", + "amount": 350000, + "counterpart": "대한건설", + "receipt_type": "법인카드", + "risk_flags": ["high_amount"] + } + ] +} +``` + +--- + +## 수정 API (6개) + +### 1. 가지급금 Summary (수정) +**현재**: 카드/가지급금/법인세/종합세 +**변경**: 카드/경조사/상품권/접대비/총합계 (5카드) + +``` +GET /api/proxy/card-transactions/summary +``` + +**Response 변경**: +```json +{ + "cards": [ + { "id": "cm1", "label": "카드", "amount": 3123000, "sub_label": "미정리 5건", "count": 5 }, + { "id": "cm2", "label": "경조사", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 }, + { "id": "cm3", "label": "상품권", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 }, + { "id": "cm4", "label": "접대비", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 }, + { "id": "cm_total", "label": "총 가지급금 합계", "amount": 350000000 } + ], + "check_points": [ + { + "id": "cm-cp1", + "type": "warning", + "message": "법인카드 사용 총 850만원이 가지급금으로 전환되었습니다.", + "highlights": [{ "text": "850만원", "color": "red" }] + } + ], + "warning_banner": "가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의" +} +``` + +**Laravel 힌트**: +- 분류: `card_transactions.category` 기준 (card/congratulation/gift_card/entertainment) +- 미정리/미증빙: `evidence_status = 'pending'` COUNT + +--- + +### 2. 접대비 Summary (수정) +**현재**: 매출/한도/잔여한도/사용금액 +**변경**: 주말심야/기피업종/고액결제/증빙미비 (리스크 4종) + +``` +GET /api/proxy/entertainment/summary +``` + +**Response 변경**: +```json +{ + "cards": [ + { "id": "et1", "label": "주말/심야", "amount": 3123000, "sub_label": "5건", "count": 5 }, + { "id": "et2", "label": "기피업종 (유흥, 귀금속 등)", "amount": 3123000, "sub_label": "불인정 5건", "count": 5 }, + { "id": "et3", "label": "고액 결제", "amount": 3123000, "sub_label": "5건", "count": 5 }, + { "id": "et4", "label": "증빙 미비", "amount": 3123000, "sub_label": "5건", "count": 5 } + ], + "check_points": [...] +} +``` + +**리스크 감지 로직** (p60 참조): +- 주말/심야: 토~일, 22:00~06:00 거래 +- 기피업종: MCC 코드 기반 (유흥업소 7273, 귀금속 5944, 골프장 7941 등) +- 고액 결제: 설정 금액(기본 50만원) 초과 +- 증빙 미비: 적격증빙(세금계산서/카드매출전표) 없는 건 + +--- + +### 3. 복리후생비 Summary (수정) +**현재**: 한도/잔여한도/사용금액 +**변경**: 비과세한도초과/사적사용의심/특정인편중/항목별한도초과 (리스크 4종) + +``` +GET /api/proxy/welfare/summary +``` + +**Response 변경**: +```json +{ + "cards": [ + { "id": "wf1", "label": "비과세 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 }, + { "id": "wf2", "label": "사적 사용 의심", "amount": 3123000, "sub_label": "5건", "count": 5 }, + { "id": "wf3", "label": "특정인 편중", "amount": 3123000, "sub_label": "5건", "count": 5 }, + { "id": "wf4", "label": "항목별 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 } + ], + "check_points": [...] +} +``` + +**리스크 감지 로직**: +- 비과세 한도 초과: 항목별 비과세 기준 초과 (식대 20만원, 교통비 10만원 등) +- 사적 사용 의심: 주말/야간 + 비업무 업종 조합 +- 특정인 편중: 직원별 사용액 편차 > 평균의 200% +- 항목별 한도 초과: 설정 금액 초과 + +--- + +### 4. 가지급금 Detail (수정) + +기존 `LoanDashboardApiResponse`에 AI분류 컬럼 추가. + +``` +GET /api/v1/loans/dashboard +``` + +**Response 추가 필드**: +```json +{ + "items": [ + { + "...기존 필드...", + "ai_category": "카드", + "evidence_status": "미증빙" + } + ] +} +``` + +--- + +### 5. 복리후생비 Detail (수정) + +기존 `WelfareDetailApiResponse`에 계산방식 파라미터 추가. + +``` +GET /api/proxy/welfare/detail?calculation_type=fixed&fixed_amount_per_month=200000 +``` + +(기존 구현 유지, 계산 파라미터만 반영 확인) + +--- + +### 6. 부가세 Detail (수정) + +기존 `VatApiResponse`에 신고기간 파라미터 반영. + +``` +GET /api/proxy/vat/summary?period_type=quarter&year=2026&period=1 +``` + +(기존 구현 유지, 기간별 필터링 확인) + +--- + +## 리스크 감지 로직 참고 (p58-60) + +### MCC 코드 기피업종 +| MCC | 업종 | 분류 | +|-----|------|------| +| 7273 | 유흥업소 | 기피업종 | +| 5944 | 귀금속 | 기피업종 | +| 7941 | 골프장 | 기피업종 | +| 5813 | 주점 | 기피업종 | +| 7011 | 호텔/리조트 | 주의업종 | + +### 리스크 판별 규칙 +``` +규칙1: 시간대 이상 → 22:00~06:00 또는 토~일 +규칙2: 업종 이상 → MCC 기피업종 해당 +규칙3: 금액 이상 → 설정 금액 초과 (기본 50만원) +규칙4: 빈도 이상 → 월 10회 이상 동일 업종 +규칙5: 증빙 미비 → 적격증빙 없음 + +리스크 등급: +- 2개 이상 해당 → 🔴 고위험 +- 1개 해당 → 🟡 주의 +- 0개 → 🟢 정상 +``` + +--- + +## 계산 공식 참고 + +### 가지급금 인정이자 (p58) +``` +인정이자 = 가지급금잔액 × (4.6% / 365) × 경과일수 +법인세 추가 = 인정이자 × 19% +대표자 소득세 = 인정이자 × 35% +``` + +### 접대비 손금한도 (p59) +``` +기본한도: + 일반법인: 1,200만원/년 + 중소기업: 3,600만원/년 + +수입금액별 추가: + 100억 이하: 수입금액 × 0.2% + 100~500억: 2,000만원 + (수입금액-100억) × 0.1% + 500억 초과: 6,000만원 + (수입금액-500억) × 0.03% +``` + +### 복리후생비 (p60) +``` +방식1 (정액): 직원수 × 월정액 × 12 +방식2 (비율): 연봉총액 × 비율% + +비과세 한도: + 식대: 20만원/월 + 교통비: 10만원/월 + 경조사: 5만원/건 + 건강검진: 연간 총액/12 환산 + 교육훈련: 8만원/월 + 복지포인트: 10만원/월 +``` + +--- + +## 우선순위 정리 + +| 우선순위 | API | 이유 | +|---------|-----|------| +| 🔴 상 | 접대비 summary 수정, 복리후생비 summary 수정 | D1.7 카드 구조 변경 | +| 🔴 상 | 가지급금 summary 수정 | D1.7 카드 구조 변경 | +| 🔴 상 | 접대비 detail 신규 | 모달 확장 | +| 🟡 중 | 매출 현황, 매입 현황, 시공 현황, 근태 현황 | 신규 섹션 | +| 🟡 중 | 생산 현황 | 복잡한 공정 집계 | +| 🟢 하 | 미출고 내역, 일별 매출/매입 | 단순 조회 | diff --git a/claudedocs/architecture/[ANALYSIS-2026-02-27] ceo-dashboard-analysis.md b/claudedocs/architecture/[ANALYSIS-2026-02-27] ceo-dashboard-analysis.md new file mode 100644 index 00000000..8ec2c525 --- /dev/null +++ b/claudedocs/architecture/[ANALYSIS-2026-02-27] ceo-dashboard-analysis.md @@ -0,0 +1,176 @@ +# CEO Dashboard 분석 (기획서 D1.7 기준) + +**기획서**: `SAM_ERP_Storyboard_D1.7_260227.pdf` p33~60 +**분석일**: 2026-02-27 +**상태**: 기획서 분석 완료, 구현 대기 + +--- + +## 1. 전체 구성 + +| 구분 | 페이지 | 수량 | +|------|--------|------| +| 메인 대시보드 섹션 | p33~43 | 20개 | +| 상세 모달 | p44~57 | 10개 | +| 참고 자료 (계산공식) | p58~60 | 3페이지 | + +--- + +## 2. 섹션별 현황 (20개) + +### API 연동 완료 (11개) + +| # | 섹션 | 페이지 | hook | API endpoint | +|---|------|--------|------|-------------| +| 1 | 오늘의 이슈 | p33 | useTodayIssue | today-issues/summary | +| 2 | 자금 현황 | p33-34 | useCEODashboard | daily-report/summary | +| 3 | 현황판 | p34 | useStatusBoard | status-board/summary | +| 4 | 당월 예상 지출 | p34-35 | useMonthlyExpense | expected-expenses/summary | +| 5 | 가지급금 현황 | p35 | useCardManagement | card-transactions/summary + 2개 | +| 6 | 접대비 현황 | p35-36 | useEntertainment | entertainment/summary | +| 7 | 복리후생비 현황 | p36 | useWelfare | welfare/summary | +| 8 | 미수금 현황 | p36 | useReceivable | receivables/summary | +| 9 | 채권추심 현황 | p37 | useDebtCollection | bad-debts/summary | +| 10 | 부가세 현황 | p37-38 | useVat | vat/summary | +| 11 | 캘린더 | p38 | useCalendar | calendar/schedules | + +### Mock 데이터만 (9개) - API 신규 필요 + +| # | 섹션 | 페이지 | 필요 데이터 | +|---|------|--------|-----------| +| 12 | 매출 현황 | p39 | 누적매출, 달성률, YoY, 당월매출 + 차트2 + 테이블 | +| 13 | 일별 매출 내역 | p47(설정) | 매출일, 거래처, 매출금액 (🆕 신규 섹션) | +| 14 | 매입 현황 | p40 | 누적매입, 미결제, YoY + 차트2 + 테이블 | +| 15 | 일별 매입 내역 | p47(설정) | 매입일, 거래처, 매입금액 (🆕 신규 섹션) | +| 16 | 생산 현황 | p41 | 공정별(스크린/슬랫/절곡) 집계 + 작업자현황 | +| 17 | 출고 현황 | p41 | 예상출고 7일/30일 금액+건수 | +| 18 | 미출고 내역 | p42 | 로트번호, 현장명, 수주처, 잔량, 납기일 | +| 19 | 시공 현황 | p42 | 진행/완료(7일이내) + 현장카드 | +| 20 | 근태 현황 | p43 | 출근/휴가/지각/결근 + 직원테이블 | + +--- + +## 3. 🔴 D1.7 핵심 변경사항 + +### 카드 구조 변경 (한도관리형 → 리스크감지형) + +| 섹션 | 기존 구현 | D1.7 기획서 | +|------|---------|-----------| +| **가지급금** | 카드, 가지급금, 법인세예상, 종합세예상 | 카드, 경조사, 상품권, 접대비, 총합계 (5카드) | +| **접대비** | 매출, 분기한도, 잔여한도, 사용금액 | **주말/심야, 기피업종, 고액결제, 증빙미비** | +| **복리후생비** | 당해한도, 분기한도, 잔여한도, 사용금액 | **비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과** | + +### 신규 섹션 (2개) +- 일별 매출 내역: 항목 설정(p47)에서 별도 ON/OFF +- 일별 매입 내역: 항목 설정(p47)에서 별도 ON/OFF + +### 설정 팝업 확장 (p45-47) +- 접대비: 한도관리(연간/반기/분기/월), 기업구분(일반법인/중소기업), 고액결제기준금액 +- 복리후생비: 한도관리, 계산방식(직원당정액 or 연봉총액×비율), 조건부입력필드, 1회결제기준금액 + +--- + +## 4. 상세 모달 (10개) + +| # | 모달 | 페이지 | 프론트 config | API 상태 | +|---|------|--------|-------------|---------| +| 1 | 일정 상세 | p44 | ✅ ScheduleDetailModal | ✅ 연동 | +| 2 | 항목 설정 | p45-47 | ✅ DashboardSettingsDialog | localStorage | +| 3 | 당월 매입 상세 | p48 | ✅ me1 config | ⚠️ 부분연동 | +| 4 | 당월 카드 상세 | p49 | ✅ me2 config | ⚠️ 부분연동 | +| 5 | 당월 발행어음 상세 | p50 | ✅ me3 config | ⚠️ 부분연동 | +| 6 | 당월 지출 예상 상세 | p51 | ✅ me4 config | ⚠️ 부분연동 | +| 7 | 가지급금 상세 | p52 | ✅ cm2 config | ⚠️ 구조변경 필요 | +| 8 | 접대비 상세 | p53-54 | ✅ et config | ⚠️ 대폭확장 | +| 9 | 복리후생비 상세 | p55-56 | ✅ wf config | ⚠️ 대폭확장 | +| 10 | 예상 납부세액 상세 | p57 | ✅ vat config | ⚠️ 확장필요 | + +--- + +## 5. 필요 API 작업 (16개) + +### 백엔드 API 수정 (6개) + +| # | API | 변경 내용 | +|---|-----|---------| +| 1 | 가지급금 summary | 카드/경조사/상품권/접대비 분류 집계 | +| 2 | 접대비 summary | 리스크 4종 (주말심야/기피업종/고액/증빙미비) - MCC코드 판별 | +| 3 | 복리후생비 summary | 리스크 4종 (비과세초과/사적사용/편중/한도초과) | +| 4 | 가지급금 detail | 분류별 상세 + AI분류 컬럼 | +| 5 | 복리후생비 detail | 계산방식별 + 분기별현황 | +| 6 | 부가세 detail | 신고기간별 + 부가세요약 + 미발행/미수취 | + +### 백엔드 API 신규 (10개) + +| # | API | 용도 | 난이도 | +|---|-----|------|--------| +| 1 | 접대비 detail | 한도계산 + 분기별현황 + 내역테이블 | 상 | +| 2 | 매출 현황 summary | 누적/달성률/YoY/당월 + 차트 | 중 | +| 3 | 일별 매출 내역 | 매출일, 거래처, 매출금액 | 하 | +| 4 | 매입 현황 summary | 누적/미결제/YoY + 차트 | 중 | +| 5 | 일별 매입 내역 | 매입일, 거래처, 매입금액 | 하 | +| 6 | 생산 현황 | 공정별 집계 + 작업자실적 | 상 | +| 7 | 출고 현황 | 7일/30일 예상출고 | 하 | +| 8 | 미출고 내역 | 납기기준 미출고 조회 | 하 | +| 9 | 시공 현황 | 진행/완료(7일이내) + 카드 | 중 | +| 10 | 근태 현황 | 출근/휴가/지각/결근 집계 | 중 | + +--- + +## 6. 프론트엔드 작업 (8개) + +| # | 작업 | 대상 | +|---|------|------| +| 1 | 가지급금 카드 구조 변경 | CardManagementSection | +| 2 | 접대비 카드 → 리스크형 | EntertainmentSection | +| 3 | 복리후생비 카드 → 리스크형 | WelfareSection | +| 4 | 일별 매출 내역 섹션 신규 | 새 컴포넌트 | +| 5 | 일별 매입 내역 섹션 신규 | 새 컴포넌트 | +| 6 | 항목 설정 팝업 업데이트 | DashboardSettingsDialog | +| 7 | 모달 config API 연동 | 각 modalConfigs | +| 8 | Mock 섹션 API 연동 | 매출~근태 hook 생성 | + +--- + +## 7. 데이터 아키텍처 + +대시보드 전용 테이블 없음. 모든 데이터는 각 도메인 페이지 입력 데이터의 실시간 집계. + +### 자금 현황 데이터 조합 +| 카드 | 출처 | +|------|------| +| 일일일보 | bank_accounts 잔액 합계 | +| 미수금 잔액 | sales 합계 - deposits 합계 | +| 미지급금 잔액 | purchases 합계 - payments 합계 | +| 당월 예상 지출 | 매입예정 + 카드결제 + 어음만기 합산 | + +### 리스크 감지 로직 (접대비/복리후생비) +- MCC 코드 기반 업종 판별 (p60: 유흥업소, 귀금속, 골프장 등) +- 체크 규칙: 시간대이상(22~06시), 업종이상, 금액이상(50만원), 빈도이상(월10회) +- 사적사용 의심: 토요일 23시 + 유흥주점 + 25만원 → 2개 규칙 해당 + +### 캐싱 +- sam_stat 테이블 5분 캐시 (백엔드 기존 구현) + +--- + +## 8. 참고 계산 공식 (p58-60) + +### 가지급금 인정이자 +- 인정이자율: 4.6% (당좌대출이자율 기준, 매년 고시) +- 인정이자 = 가지급금 × 일이자율(연이자율/365) × 경과일수 +- 법인세 추가: 인정이자 × 0.19 +- 대표자 소득세 추가: 인정이자 × 0.35 + +### 접대비 손금한도 +- 기본한도: 일반법인 1,200만원/년, 중소기업 3,600만원/년 +- 수입금액별 추가한도: + - 100억 이하: 수입금액 × 0.2% + - 100억~500억: 2,000만원 + (수입금액-100억) × 0.1% + - 500억 초과: 6,000만원 + (수입금액-500억) × 0.03% + +### 복리후생비 계산 +- 방식1 (직원당 정액): 직원수 × 월정액 × 12 +- 방식2 (연봉총액 비율): 연봉총액 × 비율% +- 법정 복리후생비: 4대보험 회사부담분 +- 비과세 항목별 기준: 식대 20만원, 교통비 10만원, 경조사 5만원, 건강검진 월환산, 교육훈련 8만원, 복지포인트 10만원 \ No newline at end of file diff --git a/src/app/[locale]/(protected)/dev/component-registry/previews.tsx b/src/app/[locale]/(protected)/dev/component-registry/previews.tsx index cb955ffe..49ed9b1a 100644 --- a/src/app/[locale]/(protected)/dev/component-registry/previews.tsx +++ b/src/app/[locale]/(protected)/dev/component-registry/previews.tsx @@ -96,6 +96,14 @@ import { DataTable } from '@/components/organisms/DataTable'; import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal'; // UI - 추가 import { VisuallyHidden } from '@/components/ui/visually-hidden'; +import { DateRangePicker } from '@/components/ui/date-range-picker'; +import { DateTimePicker } from '@/components/ui/date-time-picker'; +// Molecules - 추가 +import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover'; +import { GenericCRUDDialog } from '@/components/molecules/GenericCRUDDialog'; +import { ReorderButtons } from '@/components/molecules/ReorderButtons'; +// Organisms - 추가 +import { LineItemsTable } from '@/components/organisms/LineItemsTable/LineItemsTable'; // Lucide icons for demos import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react'; @@ -339,6 +347,89 @@ function SearchableSelectionDemo() { ); } +// ── 추가 Demo Wrappers ── + +function DateRangePickerDemo() { + const [start, setStart] = useState(); + const [end, setEnd] = useState(); + return ( +
+ +
+ ); +} + +function DateTimePickerDemo() { + const [v, setV] = useState(); + return ( +
+ +
+ ); +} + +function ColumnSettingsPopoverDemo() { + const [cols, setCols] = useState([ + { key: 'name', label: '품목명', visible: true, locked: true }, + { key: 'spec', label: '규격', visible: true, locked: false }, + { key: 'qty', label: '수량', visible: true, locked: false }, + { key: 'price', label: '단가', visible: false, locked: false }, + { key: 'note', label: '비고', visible: false, locked: false }, + ]); + return ( + setCols((prev) => prev.map((c) => (c.key === key && !c.locked ? { ...c, visible: !c.visible } : c)))} + onReset={() => setCols((prev) => prev.map((c) => ({ ...c, visible: true })))} + hasHiddenColumns={cols.some((c) => !c.visible)} + /> + ); +} + +function GenericCRUDDialogDemo() { + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} + /> + + ); +} + +function LineItemsTableDemo() { + const [items, setItems] = useState([ + { id: '1', itemName: '볼트 M10x30', quantity: 100, unitPrice: 500, supplyAmount: 50000, vat: 5000, note: '' }, + { id: '2', itemName: '너트 M10', quantity: 200, unitPrice: 300, supplyAmount: 60000, vat: 6000, note: '' }, + ]); + return ( +
+ i.itemName} + getQuantity={(i) => i.quantity} + getUnitPrice={(i) => i.unitPrice} + getSupplyAmount={(i) => i.supplyAmount} + getVat={(i) => i.vat} + getNote={(i) => i.note} + onItemChange={(idx, field, value) => setItems((prev) => prev.map((item, i) => (i === idx ? { ...item, [field]: value } : item)))} + onAddItem={() => setItems((prev) => [...prev, { id: String(prev.length + 1), itemName: '', quantity: 1, unitPrice: 0, supplyAmount: 0, vat: 0, note: '' }])} + onRemoveItem={(idx) => setItems((prev) => prev.filter((_, i) => i !== idx))} + totals={{ supplyAmount: items.reduce((s, i) => s + i.supplyAmount, 0), vat: items.reduce((s, i) => s + i.vat, 0), total: items.reduce((s, i) => s + i.supplyAmount + i.vat, 0) }} + /> +
+ ); +} + // ── Preview Registry ── type PreviewEntry = { @@ -937,6 +1028,14 @@ export const UI_PREVIEWS: Record = { }, ], + 'date-range-picker.tsx': [ + { label: 'DateRangePicker', render: () => }, + ], + + 'date-time-picker.tsx': [ + { label: 'DateTimePicker', render: () => }, + ], + // ─── Atoms ─── 'BadgeSm.tsx': [ { @@ -1184,6 +1283,36 @@ export const UI_PREVIEWS: Record = { { label: 'Filter', render: () => }, ], + 'ColumnSettingsPopover.tsx': [ + { label: 'Popover', render: () => }, + ], + + 'GenericCRUDDialog.tsx': [ + { label: 'CRUD Dialog', render: () => }, + ], + + 'ReorderButtons.tsx': [ + { + label: 'Sizes', + render: () => ( +
+
+ sm: + {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="sm" /> +
+
+ xs: + {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="xs" /> +
+
+ disabled: + {}} onMoveDown={() => {}} isFirst={true} isLast={true} size="sm" /> +
+
+ ), + }, + ], + // ─── Organisms ─── 'EmptyState.tsx': [ { @@ -1440,4 +1569,8 @@ export const UI_PREVIEWS: Record = { 'SearchableSelectionModal.tsx': [ { label: 'Modal', render: () => }, ], + + 'LineItemsTable.tsx': [ + { label: 'Line Items', render: () => }, + ], }; diff --git a/src/components/accounting/DailyReport/index.tsx b/src/components/accounting/DailyReport/index.tsx index 555889e0..31cfe33f 100644 --- a/src/components/accounting/DailyReport/index.tsx +++ b/src/components/accounting/DailyReport/index.tsx @@ -208,6 +208,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts window.print(); }, []); + // ===== USD 금액 포맷 ===== + const formatUsd = useCallback((value: number) => `$ ${formatAmount(value)}`, []); + // ===== 검색 필터링 ===== const filteredNoteReceivables = useMemo(() => { if (!searchTerm) return noteReceivables; @@ -225,6 +228,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts ); }, [dailyAccounts, searchTerm]); + // ===== USD 데이터 존재 여부 ===== + const hasUsdAccounts = useMemo(() => + filteredDailyAccounts.some(item => item.currency === 'USD'), + [filteredDailyAccounts] + ); + return ( {/* 헤더 */} @@ -290,67 +299,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts - {/* 어음 및 외상매출채권현황 */} - - -
-

어음 및 외상매출채권현황

-
-
-
- - - - 내용 - 현재 잔액 - 발행일 - 만기일 - - - - {isLoading ? ( - - -
- - 데이터를 불러오는 중... -
-
-
- ) : filteredNoteReceivables.length === 0 ? ( - - - 데이터가 없습니다. - - - ) : ( - filteredNoteReceivables.map((item) => ( - - {item.content} - {formatAmount(item.currentBalance)} - {item.issueDate} - {item.dueDate} - - )) - )} -
- {filteredNoteReceivables.length > 0 && ( - - - 합계 - {formatAmount(noteReceivableTotal)} - - - - - )} -
-
-
-
-
- - {/* 일자별 상세 */} + {/* 일자별 입출금 합계 */}
@@ -358,10 +307,10 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts 일자: {startDateInfo.formatted} {startDateInfo.dayOfWeek}
-
+
- - +
+ 구분 입금 @@ -398,6 +347,35 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts {formatAmount(item.balance)} ))} + {/* KRW 소계 */} + {hasUsdAccounts && ( + + 원화(KRW) 소계 + {formatAmount(accountTotals.krw.income)} + {formatAmount(accountTotals.krw.expense)} + {formatAmount(accountTotals.krw.balance)} + + )} + {/* USD 계좌들 */} + {hasUsdAccounts && filteredDailyAccounts + .filter(item => item.currency === 'USD') + .map((item) => ( + + {item.category} + {formatUsd(item.income)} + {formatUsd(item.expense)} + {formatUsd(item.balance)} + + ))} + {/* USD 소계 */} + {hasUsdAccounts && ( + + 외국환(USD) 소계 + {formatUsd(accountTotals.usd.income)} + {formatUsd(accountTotals.usd.expense)} + {formatUsd(accountTotals.usd.balance)} + + )} )} @@ -412,7 +390,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts )} -
+
@@ -424,11 +402,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts

예금 입출금 내역

+ {/* KRW 입출금 */}
- {/* 입금 */} + {/* KRW 입금 */}
- 입금 + 입금 (KRW)
@@ -474,10 +453,10 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts - {/* 출금 */} + {/* KRW 출금 */}
- 출금 + 출금 (KRW)
@@ -523,6 +502,162 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts + + {/* USD 입출금 — USD 데이터가 있을 때만 표시 */} + {hasUsdAccounts && ( + <> +
+

외국환(USD) 입출금 내역

+
+
+ {/* USD 입금 */} +
+
+ 입금 (USD) +
+
+
+ + + 입금처/적요 + 금액 + + + + {filteredDailyAccounts.filter(item => item.currency === 'USD' && item.income > 0).length === 0 ? ( + + + USD 입금 내역이 없습니다. + + + ) : ( + filteredDailyAccounts + .filter(item => item.currency === 'USD' && item.income > 0) + .map((item) => ( + + +
{item.category}
+
+ {formatUsd(item.income)} +
+ )) + )} +
+ + + 입금 합계 + {formatUsd(accountTotals.usd.income)} + + +
+
+
+ + {/* USD 출금 */} +
+
+ 출금 (USD) +
+
+ + + + 출금처/적요 + 금액 + + + + {filteredDailyAccounts.filter(item => item.currency === 'USD' && item.expense > 0).length === 0 ? ( + + + USD 출금 내역이 없습니다. + + + ) : ( + filteredDailyAccounts + .filter(item => item.currency === 'USD' && item.expense > 0) + .map((item) => ( + + +
{item.category}
+
+ {formatUsd(item.expense)} +
+ )) + )} +
+ + + 출금 합계 + {formatUsd(accountTotals.usd.expense)} + + +
+
+
+
+ + )} + + + + {/* 어음 및 외상매출채권현황 */} + + +
+

어음 및 외상매출채권현황

+
+
+
+ + + + 내용 + 현재 잔액 + 발행일 + 만기일 + + + + {isLoading ? ( + + +
+ + 데이터를 불러오는 중... +
+
+
+ ) : filteredNoteReceivables.length === 0 ? ( + + + 데이터가 없습니다. + + + ) : ( + filteredNoteReceivables.map((item) => ( + + {item.content} + {formatAmount(item.currentBalance)} + {item.issueDate} + {item.dueDate} + + )) + )} +
+ {filteredNoteReceivables.length > 0 && ( + + + 합계 + {formatAmount(noteReceivableTotal)} + + + + + )} +
+
+
diff --git a/src/components/accounting/VendorManagement/VendorManagementClient.tsx b/src/components/accounting/VendorManagement/VendorManagementClient.tsx index 2bf5f13b..82b1f271 100644 --- a/src/components/accounting/VendorManagement/VendorManagementClient.tsx +++ b/src/components/accounting/VendorManagement/VendorManagementClient.tsx @@ -129,6 +129,11 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana enumFilter('creditRating', creditRatingFilter), enumFilter('transactionGrade', transactionGradeFilter), enumFilter('badDebtStatus', badDebtFilter), + (items: Vendor[]) => items.filter((item) => { + if (!item.createdAt) return true; + const created = item.createdAt.slice(0, 10); + return created >= startDate && created <= endDate; + }), ]); // 정렬 @@ -154,7 +159,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana } return result; - }, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]); + }, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption, startDate, endDate]); const paginatedData = useMemo(() => { const startIndex = (currentPage - 1) * itemsPerPage; diff --git a/src/components/accounting/VendorManagement/actions.ts b/src/components/accounting/VendorManagement/actions.ts index 2752aa93..39ad783e 100644 --- a/src/components/accounting/VendorManagement/actions.ts +++ b/src/components/accounting/VendorManagement/actions.ts @@ -131,6 +131,8 @@ export async function getClients(params?: { size?: number; q?: string; only_active?: boolean; + start_date?: string; + end_date?: string; }): Promise<{ success: boolean; data: Vendor[]; total: number; error?: string }> { const result = await executeServerAction({ url: buildApiUrl('/api/v1/clients', { @@ -138,6 +140,8 @@ export async function getClients(params?: { size: params?.size, q: params?.q, only_active: params?.only_active, + start_date: params?.start_date, + end_date: params?.end_date, }), transform: (data: PaginatedResponse) => ({ items: data.data.map(transformApiToFrontend), diff --git a/src/components/accounting/VendorManagement/index.tsx b/src/components/accounting/VendorManagement/index.tsx index 5ac785a1..657f3e40 100644 --- a/src/components/accounting/VendorManagement/index.tsx +++ b/src/components/accounting/VendorManagement/index.tsx @@ -149,6 +149,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, + dateField: 'createdAt', }, // 데이터 변경 콜백 (Stats 계산용) diff --git a/src/components/approval/ApprovalBox/actions.ts b/src/components/approval/ApprovalBox/actions.ts index 711a4944..7d1805af 100644 --- a/src/components/approval/ApprovalBox/actions.ts +++ b/src/components/approval/ApprovalBox/actions.ts @@ -113,6 +113,7 @@ function transformApiToFrontend(data: InboxApiData): ApprovalRecord { export async function getInbox(params?: { page?: number; per_page?: number; search?: string; status?: string; approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc'; + start_date?: string; end_date?: string; }): Promise<{ data: ApprovalRecord[]; total: number; lastPage: number; __authError?: boolean }> { const result = await executeServerAction>({ url: buildApiUrl('/api/v1/approvals/inbox', { @@ -123,6 +124,8 @@ export async function getInbox(params?: { approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined, sort_by: params?.sort_by, sort_dir: params?.sort_dir, + start_date: params?.start_date, + end_date: params?.end_date, }), errorMessage: '결재함 목록 조회에 실패했습니다.', }); diff --git a/src/components/approval/ApprovalBox/index.tsx b/src/components/approval/ApprovalBox/index.tsx index 4139f0ab..02d8f50c 100644 --- a/src/components/approval/ApprovalBox/index.tsx +++ b/src/components/approval/ApprovalBox/index.tsx @@ -158,6 +158,8 @@ export function ApprovalBox() { search: searchQuery || undefined, status: activeTab !== 'all' ? activeTab : undefined, approval_type: filterOption !== 'all' ? filterOption : undefined, + start_date: startDate || undefined, + end_date: endDate || undefined, ...sortConfig, }); @@ -172,7 +174,7 @@ export function ApprovalBox() { setIsLoading(false); isInitialLoadDone.current = true; } - }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]); + }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]); // ===== 초기 로드 ===== useEffect(() => { diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index daf5fcc0..f83320b1 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -32,9 +32,9 @@ import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailM import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER } from './types'; import { ScheduleDetailModal, DetailModal } from './modals'; import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog'; -import { mockData } from './mockData'; import { LazySection } from './LazySection'; -import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail } from '@/hooks/useCEODashboard'; +import { EmptySection } from './components'; +import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard'; import { useCardManagementModals } from '@/hooks/useCardManagementModals'; import { getMonthlyExpenseModalConfig, @@ -47,9 +47,14 @@ import { export function CEODashboard() { const router = useRouter(); - // API 데이터 Hook (Phase 1 섹션들) + // API 데이터 Hook (신규 6개는 백엔드 API 구현 전까지 비활성) const apiData = useCEODashboard({ - cardManagementFallback: mockData.cardManagement, + salesStatus: false, + purchaseStatus: false, + dailyProduction: false, + unshipped: false, + construction: false, + dailyAttendance: false, }); // TodayIssue API Hook (Phase 2) @@ -79,6 +84,12 @@ export function CEODashboard() { apiData.monthlyExpense.loading || apiData.cardManagement.loading || apiData.statusBoard.loading || + apiData.salesStatus.loading || + apiData.purchaseStatus.loading || + apiData.dailyProduction.loading || + apiData.unshipped.loading || + apiData.construction.loading || + apiData.dailyAttendance.loading || todayIssueData.loading || calendarData.loading || vatData.loading || @@ -87,35 +98,37 @@ export function CEODashboard() { ); }, [apiData, todayIssueData.loading, calendarData.loading, vatData.loading, entertainmentData.loading, welfareData.loading]); - // API 데이터와 mockData를 병합 (API 우선, 실패 시 fallback) + // API 데이터만으로 구성 (mock 제거 — API 미응답 시 undefined → 빈 상태 UI) const data = useMemo(() => ({ - ...mockData, - // Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback - // TODO: 자금현황 카드 변경 (일일일보/미수금/미지급금/당월예상지출) - 새 API 구현 후 교체 - dailyReport: mockData.dailyReport, - // TODO: D1.7 카드 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체 - // cardManagement: 카드/경조사/상품권/접대비 (기존: 카드/가지급금/법인세/종합세) - // entertainment: 주말심야/기피업종/고액결제/증빙미비 (기존: 매출/한도/잔여한도/사용금액) - // welfare: 비과세초과/사적사용/특정인편중/한도초과 (기존: 한도/잔여한도/사용금액) - // receivable: 누적/당월/거래처/Top3 (기존: 누적/당월/거래처현황) - receivable: mockData.receivable, - debtCollection: apiData.debtCollection.data ?? mockData.debtCollection, - monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense, - cardManagement: mockData.cardManagement, - // Phase 2 섹션들 (API 연동 완료 - 목업 fallback 제거) todayIssue: apiData.statusBoard.data ?? [], todayIssueList: todayIssueData.data?.items ?? [], - calendarSchedules: calendarData.data?.items ?? mockData.calendarSchedules, - vat: vatData.data ?? mockData.vat, - entertainment: mockData.entertainment, - welfare: mockData.welfare, - // 신규 섹션 (API 미구현 - mock 데이터) - salesStatus: mockData.salesStatus, - purchaseStatus: mockData.purchaseStatus, - dailyProduction: mockData.dailyProduction, - unshipped: mockData.unshipped, - dailyAttendance: mockData.dailyAttendance, - }), [apiData, todayIssueData.data, calendarData.data, vatData.data, entertainmentData.data, welfareData.data, mockData]); + dailyReport: apiData.dailyReport.data ?? undefined, + monthlyExpense: apiData.monthlyExpense.data ?? undefined, + cardManagement: apiData.cardManagement.data ?? undefined, + entertainment: entertainmentData.data ?? undefined, + welfare: welfareData.data ?? undefined, + receivable: apiData.receivable.data ?? undefined, + debtCollection: apiData.debtCollection.data ?? undefined, + vat: vatData.data ?? undefined, + calendarSchedules: calendarData.data?.items ?? undefined, + salesStatus: apiData.salesStatus.data ?? { + cumulativeSales: 0, achievementRate: 0, yoyChange: 0, monthlySales: 0, + monthlyTrend: [], clientSales: [], dailyItems: [], dailyTotal: 0, + }, + purchaseStatus: apiData.purchaseStatus.data ?? { + cumulativePurchase: 0, unpaidAmount: 0, yoyChange: 0, + monthlyTrend: [], materialRatio: [], dailyItems: [], dailyTotal: 0, + }, + dailyProduction: apiData.dailyProduction.data ?? { + date: '', processes: [], + shipment: { expectedAmount: 0, expectedCount: 0, actualAmount: 0, actualCount: 0 }, + }, + unshipped: apiData.unshipped.data ?? { items: [] }, + constructionData: apiData.construction.data ?? { thisMonth: 0, completed: 0, items: [] }, + dailyAttendance: apiData.dailyAttendance.data ?? { + present: 0, onLeave: 0, late: 0, absent: 0, employees: [], + }, + }), [apiData, todayIssueData.data, calendarData.data, vatData.data, entertainmentData.data, welfareData.data]); // 일정 상세 모달 상태 const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false); @@ -136,6 +149,7 @@ export function CEODashboard() { // 상세 모달 상태 const [isDetailModalOpen, setIsDetailModalOpen] = useState(false); const [detailModalConfig, setDetailModalConfig] = useState(null); + const [currentModalCardId, setCurrentModalCardId] = useState(null); // 클라이언트에서만 localStorage에서 설정 불러오기 (hydration 에러 방지) useEffect(() => { @@ -204,17 +218,30 @@ export function CEODashboard() { const handleDetailModalClose = useCallback(() => { setIsDetailModalOpen(false); setDetailModalConfig(null); + setCurrentModalCardId(null); }, []); - // 당월 예상 지출 카드 클릭 (개별 카드 클릭 시 상세 모달) - // TODO: D1.7 모달 구조 변경 - 새 백엔드 API 구현 후 API 데이터로 교체 - const handleMonthlyExpenseCardClick = useCallback((cardId: string) => { - const config = getMonthlyExpenseModalConfig(cardId); + // 당월 예상 지출 카드 클릭 - API 데이터로 모달 열기 + const handleMonthlyExpenseCardClick = useCallback(async (cardId: string) => { + const config = await monthlyExpenseDetailData.fetchData(cardId as MonthlyExpenseCardId); if (config) { + setCurrentModalCardId(cardId); setDetailModalConfig(config); setIsDetailModalOpen(true); } - }, []); + }, [monthlyExpenseDetailData]); + + // 당월 예상 지출 모달 날짜/검색 필터 변경 → 재조회 + const handleDateFilterChange = useCallback(async (params: { startDate: string; endDate: string; search: string }) => { + if (!currentModalCardId) return; + const config = await monthlyExpenseDetailData.fetchData( + currentModalCardId as MonthlyExpenseCardId, + params, + ); + if (config) { + setDetailModalConfig(config); + } + }, [currentModalCardId, monthlyExpenseDetailData]); // 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체) const handleMonthlyExpenseClick = useCallback(() => { @@ -315,12 +342,13 @@ export function CEODashboard() { ); case 'dailyReport': - if (!dashboardSettings.dailyReport) return null; + if (!dashboardSettings.dailyReport || !data.dailyReport) return null; return ( handleMonthlyExpenseCardClick('me4')} /> ); @@ -337,7 +365,7 @@ export function CEODashboard() { ); case 'monthlyExpense': - if (!dashboardSettings.monthlyExpense) return null; + if (!dashboardSettings.monthlyExpense || !data.monthlyExpense) return null; return ( @@ -389,7 +417,7 @@ export function CEODashboard() { ); case 'debtCollection': - if (!dashboardSettings.debtCollection) return null; + if (!dashboardSettings.debtCollection || !data.debtCollection) return null; return ( @@ -397,7 +425,7 @@ export function CEODashboard() { ); case 'vat': - if (!dashboardSettings.vat) return null; + if (!dashboardSettings.vat || !data.vat) return null; return ( @@ -405,7 +433,7 @@ export function CEODashboard() { ); case 'calendar': - if (!dashboardSettings.calendar) return null; + if (!dashboardSettings.calendar || !data.calendarSchedules) return null; return ( )} diff --git a/src/components/business/CEODashboard/components.tsx b/src/components/business/CEODashboard/components.tsx index d90d29d5..ae61ef70 100644 --- a/src/components/business/CEODashboard/components.tsx +++ b/src/components/business/CEODashboard/components.tsx @@ -479,4 +479,19 @@ export function CollapsibleDashboardCard({ )}
); +} + +/** + * 데이터가 없거나 API 미연동 섹션에 표시하는 빈 상태 컴포넌트 + */ +export function EmptySection({ title, message = '데이터를 불러올 수 없습니다' }: { title: string; message?: string }) { + return ( + + + +

{title}

+

{message}

+
+
+ ); } \ No newline at end of file diff --git a/src/components/business/CEODashboard/dialogs/DashboardSettingsSections.tsx b/src/components/business/CEODashboard/dialogs/DashboardSettingsSections.tsx index 11a3d4e2..04088e98 100644 --- a/src/components/business/CEODashboard/dialogs/DashboardSettingsSections.tsx +++ b/src/components/business/CEODashboard/dialogs/DashboardSettingsSections.tsx @@ -35,7 +35,7 @@ export const STATUS_BOARD_LABELS: Record = { annualLeave: '연차', vehicle: '차량', equipment: '장비', - purchase: '발주', + purchase: '발주', // [2026-03-03] 비활성화 — 설정 모달에서 숨김 처리 (STATUS_BOARD_HIDDEN_SETTINGS) approvalRequest: '결재 요청', fundStatus: '자금 현황', }; @@ -123,6 +123,13 @@ export function SectionRow({ ); } +// [2026-03-03] 설정 모달에서 숨길 항목 +// - purchase: 백엔드 path 오류 + 데이터 정합성 이슈 (API-SPEC N4 참조) +// - vehicle, equipment, fundStatus: 백엔드 API에서 미제공 (StatusBoard 응답에 없음) +const STATUS_BOARD_HIDDEN_SETTINGS = new Set([ + 'purchase', 'vehicle', 'equipment', 'fundStatus', +]); + // ─── 현황판 항목 토글 리스트 ──────────────────────── export function StatusBoardItemsList({ items, @@ -133,8 +140,9 @@ export function StatusBoardItemsList({ }) { return (
- {(Object.keys(STATUS_BOARD_LABELS) as Array).map( - (itemKey) => ( + {(Object.keys(STATUS_BOARD_LABELS) as Array) + .filter((itemKey) => !STATUS_BOARD_HIDDEN_SETTINGS.has(itemKey)) + .map((itemKey) => (
void; config: DetailModalConfig; + onDateFilterChange?: (params: { startDate: string; endDate: string; search: string }) => void; } -export function DetailModal({ isOpen, onClose, config }: DetailModalProps) { +export function DetailModal({ isOpen, onClose, config, onDateFilterChange }: DetailModalProps) { return ( !open && onClose()} > @@ -51,7 +52,7 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
{/* 기간선택기 영역 */} {config.dateFilter?.enabled && ( - + )} {/* 신고기간 셀렉트 영역 */} diff --git a/src/components/business/CEODashboard/modals/DetailModalSections.tsx b/src/components/business/CEODashboard/modals/DetailModalSections.tsx index 0e8d773f..3015dd6c 100644 --- a/src/components/business/CEODashboard/modals/DetailModalSections.tsx +++ b/src/components/business/CEODashboard/modals/DetailModalSections.tsx @@ -1,7 +1,8 @@ 'use client'; import { useState, useCallback, useMemo } from 'react'; -import { Search } from 'lucide-react'; +import { Search as SearchIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { Select, SelectContent, @@ -46,7 +47,7 @@ import type { // 필터 섹션 // ============================================ -export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => { +export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilterConfig; onFilterChange?: (params: { startDate: string; endDate: string; search: string }) => void }) => { const today = new Date(); const [startDate, setStartDate] = useState(() => { const d = new Date(today.getFullYear(), today.getMonth(), 1); @@ -58,6 +59,14 @@ export const DateFilterSection = ({ config }: { config: DateFilterConfig }) => { }); const [searchText, setSearchText] = useState(''); + const handleSearch = useCallback(() => { + onFilterChange?.({ startDate, endDate, search: searchText }); + }, [startDate, endDate, searchText, onFilterChange]); + + const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') handleSearch(); + }, [handleSearch]); + return (
{ onStartDateChange={setStartDate} onEndDateChange={setEndDate} extraActions={ - config.showSearch !== false ? ( -
- - setSearchText(e.target.value)} - placeholder="검색" - className="h-8 pl-7 pr-3 text-xs w-[140px]" - /> -
- ) : undefined +
+ {config.showSearch !== false && ( +
+ + setSearchText(e.target.value)} + onKeyDown={handleSearchKeyDown} + placeholder="검색" + className="h-8 pl-7 pr-3 text-xs w-[140px]" + /> +
+ )} + {onFilterChange && ( + + )} +
} />
diff --git a/src/components/business/CEODashboard/sections/EnhancedSections.tsx b/src/components/business/CEODashboard/sections/EnhancedSections.tsx index b6d06f4b..88c4bd62 100644 --- a/src/components/business/CEODashboard/sections/EnhancedSections.tsx +++ b/src/components/business/CEODashboard/sections/EnhancedSections.tsx @@ -45,6 +45,7 @@ const formatUSD = (amount: number): string => { interface EnhancedDailyReportSectionProps { data: DailyReportData; onClick?: () => void; + onExpenseDetailClick?: () => void; } const CARD_STYLES = [ @@ -54,10 +55,18 @@ const CARD_STYLES = [ { bgClass: 'bg-orange-50 dark:bg-orange-900/30', borderClass: 'border-orange-200 dark:border-orange-800', iconBg: '#f97316', labelClass: 'text-orange-700 dark:text-orange-300', Icon: Clock }, ]; -export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyReportSectionProps) { +export function EnhancedDailyReportSection({ data, onClick, onExpenseDetailClick }: EnhancedDailyReportSectionProps) { const router = useRouter(); const handleCardClick = (card: DailyReportData['cards'][number]) => { + // dr3 (미지급금 잔액): 클릭 동작 없음 + if (card.id === 'dr3') return; + // dr4 (당월 예상 지출 합계): 상세 팝업 표시 + if (card.id === 'dr4') { + onExpenseDetailClick?.(); + return; + } + // dr1, dr2: path로 페이지 이동 if (card.path) { router.push(card.path); } else if (onClick) { @@ -86,7 +95,7 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor return (
handleCardClick(card)} >
@@ -97,25 +106,21 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor {card.label}
-
- - {card.displayValue - ? card.displayValue - : card.currency === 'USD' - ? formatUSD(card.amount) - : formatKoreanAmount(card.amount)} - - {card.changeRate && ( - - {card.changeDirection === 'up' - ? - : } - {card.changeRate} - - )} +
+ {card.displayValue + ? card.displayValue + : card.currency === 'USD' + ? formatUSD(card.amount) + : formatKoreanAmount(card.amount)}
+ {/* 기획서 D1.7 기준: 자금현황 카드에 전일 대비 미표시 — 추후 필요 시 복원 + {card.changeRate && ( +
+ + 전일 대비 {card.changeRate} +
+ )} + */}
); })} @@ -191,6 +196,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat const router = useRouter(); const handleItemClick = (path: string) => { + if (!path) return; router.push(path); }; @@ -225,7 +231,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat return (
handleItemClick(item.path)} > {/* 아이콘 + 라벨 */} @@ -290,19 +296,15 @@ const EXPENSE_CARD_CONFIGS: Array<{ ]; export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) { - // 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환) - const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0); + // 총 예상 지출: cards[3]이 API total_amount (advance 등 미표시 항목 포함) + const totalAmount = Number(data.cards[3]?.amount) || 0; return ( } title="당월 예상 지출 내역" subtitle="이달 예상 지출 정보" - rightElement={ - - 전월 대비 +15% - - } + rightElement={undefined} > {/* 카드 그리드 */}
@@ -354,7 +356,7 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
- 전월 대비 +10.5% + {data.cards[3]?.previousLabel || '전월 대비 0.0%'}
diff --git a/src/components/business/CEODashboard/sections/StatusBoardSection.tsx b/src/components/business/CEODashboard/sections/StatusBoardSection.tsx index 210bc6e4..b26b9b3b 100644 --- a/src/components/business/CEODashboard/sections/StatusBoardSection.tsx +++ b/src/components/business/CEODashboard/sections/StatusBoardSection.tsx @@ -15,7 +15,7 @@ const LABEL_TO_SETTING_KEY: Record = { '연차': 'annualLeave', '차량': 'vehicle', '장비': 'equipment', - '발주': 'purchase', + // '발주': 'purchase', // [2026-03-03] 비활성화 — transformer에서 필터링됨 (N4 참조) '결재 요청': 'approvalRequest', }; @@ -28,6 +28,7 @@ export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionPr const router = useRouter(); const handleItemClick = (path: string) => { + if (!path) return; router.push(path); }; diff --git a/src/components/business/CEODashboard/types.ts b/src/components/business/CEODashboard/types.ts index f0214142..b41ff374 100644 --- a/src/components/business/CEODashboard/types.ts +++ b/src/components/business/CEODashboard/types.ts @@ -326,19 +326,19 @@ export interface DailyAttendanceData { } // CEO Dashboard 전체 데이터 +// 모든 필드 optional: mock 제거 후 API 미구현 섹션은 undefined export interface CEODashboardData { todayIssue: TodayIssueItem[]; // 현황판용 (구 오늘의 이슈) todayIssueList: TodayIssueListItem[]; // 새 오늘의 이슈 (리스트 형태) - dailyReport: DailyReportData; - monthlyExpense: MonthlyExpenseData; - cardManagement: CardManagementData; - entertainment: EntertainmentData; - welfare: WelfareData; - receivable: ReceivableData; - debtCollection: DebtCollectionData; - vat: VatData; - calendarSchedules: CalendarScheduleItem[]; - // 신규 섹션 (API 미구현 - mock 데이터) + dailyReport?: DailyReportData; + monthlyExpense?: MonthlyExpenseData; + cardManagement?: CardManagementData; + entertainment?: EntertainmentData; + welfare?: WelfareData; + receivable?: ReceivableData; + debtCollection?: DebtCollectionData; + vat?: VatData; + calendarSchedules?: CalendarScheduleItem[]; salesStatus?: SalesStatusData; purchaseStatus?: PurchaseStatusData; dailyProduction?: DailyProductionData; diff --git a/src/components/hr/VacationManagement/index.tsx b/src/components/hr/VacationManagement/index.tsx index ef6cd17d..f67e9eb1 100644 --- a/src/components/hr/VacationManagement/index.tsx +++ b/src/components/hr/VacationManagement/index.tsx @@ -681,9 +681,9 @@ export function VacationManagement() { columns: tableColumns, - // 공통 패턴: dateRangeSelector + // 신청현황 탭에서만 날짜 필터 표시 (사용현황/부여현황은 연간 데이터) dateRangeSelector: { - enabled: true, + enabled: mainTab === 'request', startDate, endDate, onStartDateChange: setStartDate, diff --git a/src/hooks/useCEODashboard.ts b/src/hooks/useCEODashboard.ts index 2e48ab67..f3d4fc17 100644 --- a/src/hooks/useCEODashboard.ts +++ b/src/hooks/useCEODashboard.ts @@ -18,7 +18,6 @@ import type { DailyReportApiResponse, ReceivablesApiResponse, BadDebtApiResponse, - ExpectedExpenseApiResponse, CardTransactionApiResponse, StatusBoardApiResponse, TodayIssueApiResponse, @@ -27,14 +26,18 @@ import type { EntertainmentApiResponse, WelfareApiResponse, WelfareDetailApiResponse, - ExpectedExpenseDashboardDetailApiResponse, + SalesStatusApiResponse, + PurchaseStatusApiResponse, + DailyProductionApiResponse, + UnshippedApiResponse, + ConstructionApiResponse, + DailyAttendanceApiResponse, } from '@/lib/api/dashboard/types'; import { transformDailyReportResponse, transformReceivableResponse, transformDebtCollectionResponse, - transformMonthlyExpenseResponse, transformCardManagementResponse, transformStatusBoardResponse, transformTodayIssueResponse, @@ -43,9 +46,23 @@ import { transformEntertainmentResponse, transformWelfareResponse, transformWelfareDetailResponse, - transformExpectedExpenseDetailResponse, + transformPurchaseRecordsToModal, + transformCardTransactionsToModal, + transformBillRecordsToModal, + transformAllExpensesToModal, + transformSalesStatusResponse, + transformPurchaseStatusResponse, + transformDailyProductionResponse, + transformUnshippedResponse, + transformConstructionResponse, + transformDailyAttendanceResponse, } from '@/lib/api/dashboard/transformers'; +import { getPurchases } from '@/components/accounting/PurchaseManagement/actions'; +import { getCardTransactionList } from '@/components/accounting/CardTransactionInquiry/actions'; +import { getBills } from '@/components/accounting/BillManagement/actions'; +import { formatAmount } from '@/lib/api/dashboard/transformers/common'; + import type { DailyReportData, ReceivableData, @@ -59,6 +76,12 @@ import type { EntertainmentData, WelfareData, DetailModalConfig, + SalesStatusData, + PurchaseStatusData, + DailyProductionData, + UnshippedData, + ConstructionData, + DailyAttendanceData, } from '@/components/business/CEODashboard/types'; // ============================================ @@ -127,10 +150,65 @@ export function useDebtCollection() { } export function useMonthlyExpense() { - return useDashboardFetch( - 'expected-expenses/summary', - transformMonthlyExpenseResponse, - ); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchData = useCallback(async () => { + try { + setLoading(true); + setError(null); + + // 당월 날짜 범위 + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth() + 1; + const startDate = `${y}-${String(m).padStart(2, '0')}-01`; + const lastDay = new Date(y, m, 0).getDate(); + const endDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + const commonParams = { perPage: 9999, page: 1 }; + + const [purchaseResult, cardResult, billResult] = await Promise.all([ + getPurchases({ ...commonParams, startDate, endDate }), + getCardTransactionList({ ...commonParams, startDate, endDate }), + getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate }), + ]); + + const purchases = purchaseResult.success ? purchaseResult.data : []; + const cards = cardResult.success ? cardResult.data : []; + const bills = billResult.success ? billResult.data : []; + + const purchaseTotal = purchases.reduce((sum, r) => sum + r.totalAmount, 0); + const cardTotal = cards.reduce((sum, r) => sum + r.totalAmount, 0); + const billTotal = bills.reduce((sum, r) => sum + r.amount, 0); + const grandTotal = purchaseTotal + cardTotal + billTotal; + + const result: MonthlyExpenseData = { + cards: [ + { id: 'me1', label: '매입', amount: purchaseTotal }, + { id: 'me2', label: '카드', amount: cardTotal }, + { id: 'me3', label: '발행어음', amount: billTotal }, + { id: 'me4', label: '총 예상 지출 합계', amount: grandTotal }, + ], + checkPoints: grandTotal > 0 + ? [{ id: 'me-total', type: 'info' as const, message: `이번 달 예상 지출은 ${formatAmount(grandTotal)}입니다.`, highlights: [{ text: formatAmount(grandTotal), color: 'blue' as const }] }] + : [{ id: 'me-total', type: 'info' as const, message: '이번 달 예상 지출이 없습니다.' }], + }; + + setData(result); + } catch (err) { + setError(err instanceof Error ? err.message : '데이터 로딩 실패'); + console.error('MonthlyExpense API Error:', err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { data, loading, error, refetch: fetchData }; } // ============================================ @@ -393,7 +471,73 @@ export function useWelfareDetail(options: UseWelfareDetailOptions = {}) { } // ============================================ -// 13. MonthlyExpenseDetail Hook (당월 예상 지출 상세 - 통합 모달용) +// 13. SalesStatus Hook (매출 현황) +// ============================================ + +export function useSalesStatus() { + return useDashboardFetch( + 'dashboard/sales/summary', + transformSalesStatusResponse, + ); +} + +// ============================================ +// 14. PurchaseStatus Hook (매입 현황) +// ============================================ + +export function usePurchaseStatus() { + return useDashboardFetch( + 'dashboard/purchases/summary', + transformPurchaseStatusResponse, + ); +} + +// ============================================ +// 15. DailyProduction Hook (생산 현황) +// ============================================ + +export function useDailyProduction() { + return useDashboardFetch( + 'dashboard/production/summary', + transformDailyProductionResponse, + ); +} + +// ============================================ +// 16. Unshipped Hook (미출고 내역) +// ============================================ + +export function useUnshipped() { + return useDashboardFetch( + 'dashboard/unshipped/summary', + transformUnshippedResponse, + ); +} + +// ============================================ +// 17. Construction Hook (시공 현황) +// ============================================ + +export function useConstruction() { + return useDashboardFetch( + 'dashboard/construction/summary', + transformConstructionResponse, + ); +} + +// ============================================ +// 18. DailyAttendance Hook (근태 현황) +// ============================================ + +export function useDailyAttendance() { + return useDashboardFetch( + 'dashboard/attendance/summary', + transformDailyAttendanceResponse, + ); +} + +// ============================================ +// 19. MonthlyExpenseDetail Hook (당월 예상 지출 상세 - 통합 모달용) // ============================================ export type MonthlyExpenseCardId = 'me1' | 'me2' | 'me3' | 'me4'; @@ -403,32 +547,45 @@ export function useMonthlyExpenseDetail() { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const fetchData = useCallback(async (cardId: MonthlyExpenseCardId) => { + const fetchData = useCallback(async (cardId: MonthlyExpenseCardId, filterParams?: { startDate?: string; endDate?: string; search?: string }) => { try { setLoading(true); setError(null); - const transactionTypeMap: Record = { - me1: 'purchase', - me2: 'card', - me3: 'bill', - me4: null, - }; - const transactionType = transactionTypeMap[cardId]; + // 당월 기본 날짜 범위 + const now = new Date(); + const startDate = filterParams?.startDate || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-01`; + const endDate = filterParams?.endDate || `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate()).padStart(2, '0')}`; + const search = filterParams?.search; - const endpoint = transactionType - ? `/api/proxy/expected-expenses/dashboard-detail?transaction_type=${transactionType}` - : '/api/proxy/expected-expenses/dashboard-detail'; + // 전체 데이터 가져오기 (perPage 크게 설정) + const commonParams = { perPage: 9999, page: 1 }; - const response = await fetch(endpoint); - if (!response.ok) throw new Error(`API 오류: ${response.status}`); - const result = await response.json(); - if (!result.success) throw new Error(result.message || '데이터 조회 실패'); + let transformed: DetailModalConfig; + + if (cardId === 'me1') { + const result = await getPurchases({ ...commonParams, startDate, endDate, search }); + transformed = transformPurchaseRecordsToModal(result.success ? result.data : []); + } else if (cardId === 'me2') { + const result = await getCardTransactionList({ ...commonParams, startDate, endDate, search }); + transformed = transformCardTransactionsToModal(result.success ? result.data : []); + } else if (cardId === 'me3') { + const result = await getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate, search }); + transformed = transformBillRecordsToModal(result.success ? result.data : []); + } else { + // me4: 3개 모두 호출 후 합산 + const [purchaseResult, cardResult, billResult] = await Promise.all([ + getPurchases({ ...commonParams, startDate, endDate, search }), + getCardTransactionList({ ...commonParams, startDate, endDate, search }), + getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate, search }), + ]); + transformed = transformAllExpensesToModal( + purchaseResult.success ? purchaseResult.data : [], + cardResult.success ? cardResult.data : [], + billResult.success ? billResult.data : [], + ); + } - const transformed = transformExpectedExpenseDetailResponse( - result.data as ExpectedExpenseDashboardDetailApiResponse, - cardId, - ); setModalConfig(transformed); return transformed; } catch (err) { @@ -456,15 +613,33 @@ export interface UseCEODashboardOptions { cardManagement?: boolean; cardManagementFallback?: CardManagementData; statusBoard?: boolean; + salesStatus?: boolean; + purchaseStatus?: boolean; + dailyProduction?: boolean; + unshipped?: boolean; + construction?: boolean; + dailyAttendance?: boolean; +} + +interface SectionState { + data: T | null; + loading: boolean; + error: string | null; } export interface CEODashboardState { - dailyReport: { data: DailyReportData | null; loading: boolean; error: string | null }; - receivable: { data: ReceivableData | null; loading: boolean; error: string | null }; - debtCollection: { data: DebtCollectionData | null; loading: boolean; error: string | null }; - monthlyExpense: { data: MonthlyExpenseData | null; loading: boolean; error: string | null }; - cardManagement: { data: CardManagementData | null; loading: boolean; error: string | null }; - statusBoard: { data: TodayIssueItem[] | null; loading: boolean; error: string | null }; + dailyReport: SectionState; + receivable: SectionState; + debtCollection: SectionState; + monthlyExpense: SectionState; + cardManagement: SectionState; + statusBoard: SectionState; + salesStatus: SectionState; + purchaseStatus: SectionState; + dailyProduction: SectionState; + unshipped: SectionState; + construction: SectionState; + dailyAttendance: SectionState; refetchAll: () => void; } @@ -477,6 +652,12 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo cardManagement: enableCardManagement = true, cardManagementFallback, statusBoard: enableStatusBoard = true, + salesStatus: enableSalesStatus = true, + purchaseStatus: enablePurchaseStatus = true, + dailyProduction: enableDailyProduction = true, + unshipped: enableUnshipped = true, + construction: enableConstruction = true, + dailyAttendance: enableDailyAttendance = true, } = options; // 비활성 섹션은 endpoint를 null로 → useDashboardFetch가 skip @@ -495,16 +676,93 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo transformDebtCollectionResponse, { initialLoading: enableDebtCollection }, ); - const me = useDashboardFetch( - enableMonthlyExpense ? 'expected-expenses/summary' : null, - transformMonthlyExpenseResponse, - { initialLoading: enableMonthlyExpense }, - ); const sb = useDashboardFetch( enableStatusBoard ? 'status-board/summary' : null, transformStatusBoardResponse, { initialLoading: enableStatusBoard }, ); + const ss = useDashboardFetch( + enableSalesStatus ? 'sales/summary' : null, + transformSalesStatusResponse, + { initialLoading: enableSalesStatus }, + ); + const ps = useDashboardFetch( + enablePurchaseStatus ? 'purchases/summary' : null, + transformPurchaseStatusResponse, + { initialLoading: enablePurchaseStatus }, + ); + const dp = useDashboardFetch( + enableDailyProduction ? 'production/summary' : null, + transformDailyProductionResponse, + { initialLoading: enableDailyProduction }, + ); + const us = useDashboardFetch( + enableUnshipped ? 'unshipped/summary' : null, + transformUnshippedResponse, + { initialLoading: enableUnshipped }, + ); + const cs = useDashboardFetch( + enableConstruction ? 'construction/summary' : null, + transformConstructionResponse, + { initialLoading: enableConstruction }, + ); + const da = useDashboardFetch( + enableDailyAttendance ? 'attendance/summary' : null, + transformDailyAttendanceResponse, + { initialLoading: enableDailyAttendance }, + ); + + // MonthlyExpense: 커스텀 (3개 페이지 API 병렬) + const [meData, setMeData] = useState(null); + const [meLoading, setMeLoading] = useState(enableMonthlyExpense); + const [meError, setMeError] = useState(null); + + const fetchME = useCallback(async () => { + if (!enableMonthlyExpense) return; + try { + setMeLoading(true); + setMeError(null); + const now = new Date(); + const y = now.getFullYear(); + const m = now.getMonth() + 1; + const startDate = `${y}-${String(m).padStart(2, '0')}-01`; + const lastDay = new Date(y, m, 0).getDate(); + const endDate = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; + const commonParams = { perPage: 9999, page: 1 }; + + const [purchaseResult, cardResult, billResult] = await Promise.all([ + getPurchases({ ...commonParams, startDate, endDate }), + getCardTransactionList({ ...commonParams, startDate, endDate }), + getBills({ ...commonParams, issueStartDate: startDate, issueEndDate: endDate }), + ]); + + const purchases = purchaseResult.success ? purchaseResult.data : []; + const cards = cardResult.success ? cardResult.data : []; + const bills = billResult.success ? billResult.data : []; + + const purchaseTotal = purchases.reduce((sum, r) => sum + r.totalAmount, 0); + const cardTotal = cards.reduce((sum, r) => sum + r.totalAmount, 0); + const billTotal = bills.reduce((sum, r) => sum + r.amount, 0); + const grandTotal = purchaseTotal + cardTotal + billTotal; + + setMeData({ + cards: [ + { id: 'me1', label: '매입', amount: purchaseTotal }, + { id: 'me2', label: '카드', amount: cardTotal }, + { id: 'me3', label: '발행어음', amount: billTotal }, + { id: 'me4', label: '총 예상 지출 합계', amount: grandTotal }, + ], + checkPoints: grandTotal > 0 + ? [{ id: 'me-total', type: 'info' as const, message: `이번 달 예상 지출은 ${formatAmount(grandTotal)}입니다.`, highlights: [{ text: formatAmount(grandTotal), color: 'blue' as const }] }] + : [{ id: 'me-total', type: 'info' as const, message: '이번 달 예상 지출이 없습니다.' }], + }); + } catch (err) { + setMeError(err instanceof Error ? err.message : '데이터 로딩 실패'); + console.error('MonthlyExpense API Error:', err); + } finally { + setMeLoading(false); + } + }, [enableMonthlyExpense]); // CardManagement: 커스텀 (3개 API 병렬) const [cmData, setCmData] = useState(null); @@ -527,26 +785,39 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo }, [enableCardManagement, cardManagementFallback]); useEffect(() => { + fetchME(); fetchCM(); - }, [fetchCM]); + }, [fetchME, fetchCM]); const refetchAll = useCallback(() => { dr.refetch(); rv.refetch(); dc.refetch(); - me.refetch(); + fetchME(); fetchCM(); sb.refetch(); + ss.refetch(); + ps.refetch(); + dp.refetch(); + us.refetch(); + cs.refetch(); + da.refetch(); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch]); + }, [dr.refetch, rv.refetch, dc.refetch, fetchME, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]); return { dailyReport: { data: dr.data, loading: dr.loading, error: dr.error }, receivable: { data: rv.data, loading: rv.loading, error: rv.error }, debtCollection: { data: dc.data, loading: dc.loading, error: dc.error }, - monthlyExpense: { data: me.data, loading: me.loading, error: me.error }, + monthlyExpense: { data: meData, loading: meLoading, error: meError }, cardManagement: { data: cmData, loading: cmLoading, error: cmError }, statusBoard: { data: sb.data, loading: sb.loading, error: sb.error }, + salesStatus: { data: ss.data, loading: ss.loading, error: ss.error }, + purchaseStatus: { data: ps.data, loading: ps.loading, error: ps.error }, + dailyProduction: { data: dp.data, loading: dp.loading, error: dp.error }, + unshipped: { data: us.data, loading: us.loading, error: us.error }, + construction: { data: cs.data, loading: cs.loading, error: cs.error }, + dailyAttendance: { data: da.data, loading: da.loading, error: da.error }, refetchAll, }; } \ No newline at end of file diff --git a/src/lib/api/dashboard/transformers.ts b/src/lib/api/dashboard/transformers.ts index b70639cd..a1fd037e 100644 --- a/src/lib/api/dashboard/transformers.ts +++ b/src/lib/api/dashboard/transformers.ts @@ -11,4 +11,7 @@ export { transformMonthlyExpenseResponse, transformCardManagementResponse } from export { transformStatusBoardResponse, transformTodayIssueResponse } from './transformers/status-issue'; export { transformCalendarResponse } from './transformers/calendar'; export { transformVatResponse, transformEntertainmentResponse, transformWelfareResponse, transformWelfareDetailResponse } from './transformers/tax-benefits'; -export { transformPurchaseDetailResponse, transformCardDetailResponse, transformBillDetailResponse, transformExpectedExpenseDetailResponse } from './transformers/expense-detail'; +export { transformPurchaseDetailResponse, transformCardDetailResponse, transformBillDetailResponse, transformExpectedExpenseDetailResponse, transformPurchaseRecordsToModal, transformCardTransactionsToModal, transformBillRecordsToModal, transformAllExpensesToModal } from './transformers/expense-detail'; +export { transformSalesStatusResponse, transformPurchaseStatusResponse } from './transformers/sales-purchase'; +export { transformDailyProductionResponse, transformUnshippedResponse, transformConstructionResponse } from './transformers/production-logistics'; +export { transformDailyAttendanceResponse } from './transformers/hr'; diff --git a/src/lib/api/dashboard/transformers/daily-report.ts b/src/lib/api/dashboard/transformers/daily-report.ts index d6d41c82..9066ac07 100644 --- a/src/lib/api/dashboard/transformers/daily-report.ts +++ b/src/lib/api/dashboard/transformers/daily-report.ts @@ -8,7 +8,7 @@ import type { CheckPoint, CheckPointType, } from '@/components/business/CEODashboard/types'; -import { formatAmount, formatDate, toChangeFields } from './common'; +import { formatAmount, formatDate } from './common'; /** * 운영자금 안정성에 따른 색상 반환 @@ -137,51 +137,32 @@ function generateDailyReportCheckPoints(api: DailyReportApiResponse): CheckPoint * DailyReport API 응답 → Frontend 타입 변환 */ export function transformDailyReportResponse(api: DailyReportApiResponse): DailyReportData { - const change = api.daily_change; - - // TODO: 백엔드 daily_change 필드 제공 시 더미값 제거 - const FALLBACK_CHANGES = { - cash_asset: { changeRate: '+5.2%', changeDirection: 'up' as const }, - foreign_currency: { changeRate: '+2.1%', changeDirection: 'up' as const }, - income: { changeRate: '+12.0%', changeDirection: 'up' as const }, - expense: { changeRate: '-8.0%', changeDirection: 'down' as const }, - }; - return { date: formatDate(api.date, api.day_of_week), cards: [ { id: 'dr1', - label: '현금성 자산 합계', + label: '일일일보', amount: api.cash_asset_total, - ...(change?.cash_asset_change_rate !== undefined - ? toChangeFields(change.cash_asset_change_rate) - : FALLBACK_CHANGES.cash_asset), + path: '/ko/accounting/daily-report', }, { id: 'dr2', - label: '외국환(USD) 합계', - amount: api.foreign_currency_total, - currency: 'USD', - ...(change?.foreign_currency_change_rate !== undefined - ? toChangeFields(change.foreign_currency_change_rate) - : FALLBACK_CHANGES.foreign_currency), + label: '미수금 잔액', + amount: api.receivable_balance ?? 0, + path: '/ko/accounting/receivables-status', }, { id: 'dr3', - label: '입금 합계', - amount: api.krw_totals.income, - ...(change?.income_change_rate !== undefined - ? toChangeFields(change.income_change_rate) - : FALLBACK_CHANGES.income), + label: '미지급금 잔액', + amount: api.payable_balance ?? 0, + // 클릭 이동 없음 }, { id: 'dr4', - label: '출금 합계', - amount: api.krw_totals.expense, - ...(change?.expense_change_rate !== undefined - ? toChangeFields(change.expense_change_rate) - : FALLBACK_CHANGES.expense), + label: '당월 예상 지출 합계', + amount: api.monthly_expense_total ?? 0, + // 클릭 시 당월 예상 지출 상세 팝업 (UI에서 처리) }, ], checkPoints: generateDailyReportCheckPoints(api), diff --git a/src/lib/api/dashboard/transformers/expense-detail.ts b/src/lib/api/dashboard/transformers/expense-detail.ts index d32e9c56..0dc90942 100644 --- a/src/lib/api/dashboard/transformers/expense-detail.ts +++ b/src/lib/api/dashboard/transformers/expense-detail.ts @@ -8,7 +8,15 @@ import type { BillDashboardDetailApiResponse, ExpectedExpenseDashboardDetailApiResponse, } from '../types'; -import type { DetailModalConfig } from '@/components/business/CEODashboard/types'; +import type { DateFilterConfig, DetailModalConfig } from '@/components/business/CEODashboard/types'; +import type { PurchaseRecord } from '@/components/accounting/PurchaseManagement/types'; +import type { CardTransaction } from '@/components/accounting/CardTransactionInquiry/types'; +import type { BillRecord } from '@/components/accounting/BillManagement/types'; +import { PURCHASE_TYPE_LABELS } from '@/components/accounting/PurchaseManagement/types'; +import { getBillStatusLabel } from '@/components/accounting/BillManagement/types'; + +// 차트 색상 팔레트 +const CHART_COLORS = ['#60A5FA', '#34D399', '#F59E0B', '#F87171', '#A78BFA', '#94A3B8']; // ============================================ // Purchase Dashboard Detail 변환 (me1) @@ -268,6 +276,7 @@ const EXPENSE_CARD_CONFIG: Record = { me1: { title: '당월 매입 상세', @@ -278,6 +287,7 @@ const EXPENSE_CARD_CONFIG: Record(); + let total = 0; + for (const r of records) { + const name = r.vendorName || '미지정'; + vendorMap.set(name, (vendorMap.get(name) || 0) + r.amount); + total += r.amount; + } + const sorted = [...vendorMap.entries()].sort((a, b) => b[1] - a[1]); + const top = sorted.slice(0, limit); + const otherTotal = sorted.slice(limit).reduce((sum, [, v]) => sum + v, 0); + if (otherTotal > 0) top.push(['기타', otherTotal]); + + return top.map(([name, value], idx) => ({ + name, + value, + percentage: total > 0 ? Math.round((value / total) * 1000) / 10 : 0, + color: CHART_COLORS[idx % CHART_COLORS.length], + })); +} + +/** + * PurchaseRecord[] → DetailModalConfig (me1 매입) + */ +export function transformPurchaseRecordsToModal( + records: PurchaseRecord[], + dateFilter?: DateFilterConfig, +): DetailModalConfig { + const totalAmount = records.reduce((sum, r) => sum + r.totalAmount, 0); + const totalSupply = records.reduce((sum, r) => sum + r.supplyAmount, 0); + const totalVat = records.reduce((sum, r) => sum + r.vat, 0); + + const vendorData = groupByVendor( + records.map(r => ({ vendorName: r.vendorName, amount: r.totalAmount })), + ); + + return { + title: '당월 매입 상세', + dateFilter: dateFilter ?? { enabled: true, defaultPreset: '당월', showSearch: true }, + summaryCards: [ + { label: '총 매입액', value: totalAmount, unit: '원' }, + { label: '공급가액', value: totalSupply, unit: '원' }, + { label: '부가세', value: totalVat, unit: '원' }, + { label: '건수', value: records.length, unit: '건' }, + ], + pieChart: vendorData.length > 0 ? { + title: '거래처별 매입 비율', + data: vendorData, + } : undefined, + table: { + title: '매입 내역', + columns: [ + { key: 'no', label: 'No.', align: 'center' }, + { key: 'purchaseDate', label: '매입일자', align: 'center', format: 'date' }, + { key: 'vendorName', label: '거래처명', align: 'left' }, + { key: 'supplyAmount', label: '공급가액', align: 'right', format: 'currency' }, + { key: 'vat', label: '부가세', align: 'right', format: 'currency' }, + { key: 'totalAmount', label: '합계', align: 'right', format: 'currency' }, + { key: 'purchaseType', label: '유형', align: 'center' }, + ], + data: records.map((r, idx) => ({ + no: idx + 1, + purchaseDate: r.purchaseDate, + vendorName: r.vendorName, + supplyAmount: r.supplyAmount, + vat: r.vat, + totalAmount: r.totalAmount, + purchaseType: PURCHASE_TYPE_LABELS[r.purchaseType] || r.purchaseType, + })), + showTotal: true, + totalLabel: '합계', + totalValue: totalAmount, + totalColumnKey: 'totalAmount', + footerSummary: [ + { label: `총 ${records.length}건`, value: totalAmount, format: 'currency' }, + ], + }, + }; +} + +/** + * CardTransaction[] → DetailModalConfig (me2 카드) + */ +export function transformCardTransactionsToModal( + records: CardTransaction[], + dateFilter?: DateFilterConfig, +): DetailModalConfig { + const totalAmount = records.reduce((sum, r) => sum + r.totalAmount, 0); + + const vendorData = groupByVendor( + records.map(r => ({ vendorName: r.merchantName || r.vendorName, amount: r.totalAmount })), + ); + + return { + title: '당월 카드 상세', + dateFilter: dateFilter ?? { enabled: true, defaultPreset: '당월', showSearch: true }, + summaryCards: [ + { label: '총 사용액', value: totalAmount, unit: '원' }, + { label: '건수', value: records.length, unit: '건' }, + ], + pieChart: vendorData.length > 0 ? { + title: '가맹점별 카드 사용 비율', + data: vendorData, + } : undefined, + table: { + title: '카드 사용 내역', + columns: [ + { key: 'no', label: 'No.', align: 'center' }, + { key: 'usedAt', label: '사용일자', align: 'center', format: 'date' }, + { key: 'cardName', label: '카드명', align: 'left' }, + { key: 'user', label: '사용자', align: 'center' }, + { key: 'merchantName', label: '가맹점명', align: 'left' }, + { key: 'totalAmount', label: '사용금액', align: 'right', format: 'currency' }, + ], + data: records.map((r, idx) => ({ + no: idx + 1, + usedAt: r.usedAt, + cardName: r.cardName, + user: r.user, + merchantName: r.merchantName, + totalAmount: r.totalAmount, + })), + showTotal: true, + totalLabel: '합계', + totalValue: totalAmount, + totalColumnKey: 'totalAmount', + footerSummary: [ + { label: `총 ${records.length}건`, value: totalAmount, format: 'currency' }, + ], + }, + }; +} + +/** + * BillRecord[] → DetailModalConfig (me3 발행어음) + */ +export function transformBillRecordsToModal( + records: BillRecord[], + dateFilter?: DateFilterConfig, +): DetailModalConfig { + const totalAmount = records.reduce((sum, r) => sum + r.amount, 0); + + const vendorBarData = groupByVendor( + records.map(r => ({ vendorName: r.vendorName, amount: r.amount })), + ); + + return { + title: '당월 발행어음 상세', + dateFilter: dateFilter ?? { + enabled: true, + presets: ['당해년도', '전전월', '전월', '당월', '어제'], + defaultPreset: '당월', + showSearch: true, + }, + summaryCards: [ + { label: '총 발행어음', value: totalAmount, unit: '원' }, + { label: '건수', value: records.length, unit: '건' }, + ], + horizontalBarChart: vendorBarData.length > 0 ? { + title: '거래처별 발행어음', + data: vendorBarData.map(d => ({ name: d.name, value: d.value })), + dataKey: 'value', + yAxisKey: 'name', + color: '#8B5CF6', + } : undefined, + table: { + title: '발행어음 내역', + columns: [ + { key: 'no', label: 'No.', align: 'center' }, + { key: 'billNumber', label: '어음번호', align: 'center' }, + { key: 'vendorName', label: '거래처명', align: 'left' }, + { key: 'issueDate', label: '발행일', align: 'center', format: 'date' }, + { key: 'maturityDate', label: '만기일', align: 'center', format: 'date' }, + { key: 'amount', label: '금액', align: 'right', format: 'currency' }, + { key: 'status', label: '상태', align: 'center' }, + ], + data: records.map((r, idx) => ({ + no: idx + 1, + billNumber: r.billNumber, + vendorName: r.vendorName, + issueDate: r.issueDate, + maturityDate: r.maturityDate, + amount: r.amount, + status: getBillStatusLabel(r.billType, r.status), + })), + showTotal: true, + totalLabel: '합계', + totalValue: totalAmount, + totalColumnKey: 'amount', + footerSummary: [ + { label: `총 ${records.length}건`, value: totalAmount, format: 'currency' }, + ], + }, + }; +} + +/** + * 3개 합산 → DetailModalConfig (me4 총 예상 지출) + */ +export function transformAllExpensesToModal( + purchases: PurchaseRecord[], + cards: CardTransaction[], + bills: BillRecord[], +): DetailModalConfig { + const purchaseTotal = purchases.reduce((sum, r) => sum + r.totalAmount, 0); + const cardTotal = cards.reduce((sum, r) => sum + r.totalAmount, 0); + const billTotal = bills.reduce((sum, r) => sum + r.amount, 0); + const grandTotal = purchaseTotal + cardTotal + billTotal; + const totalCount = purchases.length + cards.length + bills.length; + + // 3개 소스를 하나의 테이블로 합침 + type UnifiedRow = { date: string; type: string; vendorName: string; amount: number }; + const allRows: UnifiedRow[] = [ + ...purchases.map(r => ({ + date: r.purchaseDate, + type: '매입', + vendorName: r.vendorName, + amount: r.totalAmount, + })), + ...cards.map(r => ({ + date: r.usedAt?.split(' ')[0] || r.usedAt, // 'YYYY-MM-DD HH:mm' → 'YYYY-MM-DD' + type: '카드', + vendorName: r.merchantName || r.vendorName, + amount: r.totalAmount, + })), + ...bills.map(r => ({ + date: r.issueDate, + type: '어음', + vendorName: r.vendorName, + amount: r.amount, + })), + ]; + // 날짜순 정렬 + allRows.sort((a, b) => a.date.localeCompare(b.date)); + + return { + title: '당월 지출 예상 상세', + summaryCards: [ + { label: '총 지출 예상액', value: grandTotal, unit: '원' }, + { label: '매입', value: purchaseTotal, unit: '원' }, + { label: '카드', value: cardTotal, unit: '원' }, + { label: '어음', value: billTotal, unit: '원' }, + ], + table: { + title: '당월 지출 승인 내역서', + columns: [ + { key: 'no', label: 'No.', align: 'center' }, + { key: 'date', label: '일자', align: 'center', format: 'date' }, + { key: 'type', label: '유형', align: 'center' }, + { key: 'vendorName', label: '거래처명', align: 'left' }, + { key: 'amount', label: '금액', align: 'right', format: 'currency' }, + ], + data: allRows.map((r, idx) => ({ + no: idx + 1, + date: r.date, + type: r.type, + vendorName: r.vendorName, + amount: r.amount, + })), + filters: [ + { + key: 'type', + options: [ + { value: 'all', label: '전체' }, + { value: '매입', label: '매입' }, + { value: '카드', label: '카드' }, + { value: '어음', label: '어음' }, + ], + defaultValue: 'all', + }, + ], + showTotal: true, + totalLabel: '합계', + totalValue: grandTotal, + totalColumnKey: 'amount', + footerSummary: [ + { label: `총 ${totalCount}건`, value: grandTotal, format: 'currency' }, + ], + }, + }; +} diff --git a/src/lib/api/dashboard/transformers/hr.ts b/src/lib/api/dashboard/transformers/hr.ts new file mode 100644 index 00000000..309b9bc1 --- /dev/null +++ b/src/lib/api/dashboard/transformers/hr.ts @@ -0,0 +1,32 @@ +/** + * 근태 현황 (HR/Attendance) 변환 + */ + +import type { DailyAttendanceApiResponse } from '../types'; +import type { DailyAttendanceData } from '@/components/business/CEODashboard/types'; + +const ATTENDANCE_STATUS_MAP: Record = { + present: '출근', + on_leave: '휴가', + late: '지각', + absent: '결근', +}; + +/** + * DailyAttendance API 응답 → Frontend DailyAttendanceData 변환 + */ +export function transformDailyAttendanceResponse(api: DailyAttendanceApiResponse): DailyAttendanceData { + return { + present: api.present, + onLeave: api.on_leave, + late: api.late, + absent: api.absent, + employees: api.employees.map((emp) => ({ + id: emp.id, + department: emp.department, + position: emp.position, + name: emp.name, + status: ATTENDANCE_STATUS_MAP[emp.status] ?? '출근', + })), + }; +} diff --git a/src/lib/api/dashboard/transformers/production-logistics.ts b/src/lib/api/dashboard/transformers/production-logistics.ts new file mode 100644 index 00000000..ddc9c194 --- /dev/null +++ b/src/lib/api/dashboard/transformers/production-logistics.ts @@ -0,0 +1,105 @@ +/** + * 생산/물류 현황 (Production/Logistics) 변환 + */ + +import type { + DailyProductionApiResponse, + UnshippedApiResponse, + ConstructionApiResponse, +} from '../types'; +import type { + DailyProductionData, + UnshippedData, + ConstructionData, +} from '@/components/business/CEODashboard/types'; + +const WORK_STATUS_MAP: Record = { + in_progress: '진행중', + pending: '대기', + completed: '완료', +}; + +const CONSTRUCTION_STATUS_MAP: Record = { + in_progress: '진행중', + scheduled: '예정', + completed: '완료', +}; + +/** + * DailyProduction API 응답 → Frontend DailyProductionData 변환 + */ +export function transformDailyProductionResponse(api: DailyProductionApiResponse): DailyProductionData { + const dateObj = new Date(api.date); + const dayNames = ['일요일', '월요일', '화요일', '수요일', '목요일', '금요일', '토요일']; + const formattedDate = `${dateObj.getFullYear()}년 ${dateObj.getMonth() + 1}월 ${dateObj.getDate()}일 ${api.day_of_week || dayNames[dateObj.getDay()]}`; + + return { + date: formattedDate, + processes: api.processes.map((proc) => ({ + processName: proc.process_name, + totalWork: proc.total_work, + todo: proc.todo, + inProgress: proc.in_progress, + completed: proc.completed, + urgent: proc.urgent, + subLine: proc.sub_line, + regular: proc.regular, + workerCount: proc.worker_count, + workItems: proc.work_items.map((item) => ({ + id: item.id, + orderNo: item.order_no, + client: item.client, + product: item.product, + quantity: item.quantity, + status: WORK_STATUS_MAP[item.status] ?? '대기', + })), + workers: proc.workers.map((w) => ({ + name: w.name, + assigned: w.assigned, + completed: w.completed, + rate: w.rate, + })), + })), + shipment: { + expectedAmount: api.shipment.expected_amount, + expectedCount: api.shipment.expected_count, + actualAmount: api.shipment.actual_amount, + actualCount: api.shipment.actual_count, + }, + }; +} + +/** + * Unshipped API 응답 → Frontend UnshippedData 변환 + */ +export function transformUnshippedResponse(api: UnshippedApiResponse): UnshippedData { + return { + items: api.items.map((item) => ({ + id: item.id, + portNo: item.port_no, + siteName: item.site_name, + orderClient: item.order_client, + dueDate: item.due_date, + daysLeft: item.days_left, + })), + }; +} + +/** + * Construction API 응답 → Frontend ConstructionData 변환 + */ +export function transformConstructionResponse(api: ConstructionApiResponse): ConstructionData { + return { + thisMonth: api.this_month, + completed: api.completed, + items: api.items.map((item) => ({ + id: item.id, + siteName: item.site_name, + client: item.client, + startDate: item.start_date, + endDate: item.end_date, + progress: item.progress, + status: CONSTRUCTION_STATUS_MAP[item.status] ?? '진행중', + })), + }; +} diff --git a/src/lib/api/dashboard/transformers/sales-purchase.ts b/src/lib/api/dashboard/transformers/sales-purchase.ts new file mode 100644 index 00000000..b0b70a9d --- /dev/null +++ b/src/lib/api/dashboard/transformers/sales-purchase.ts @@ -0,0 +1,75 @@ +/** + * 매출/매입 현황 (Sales/Purchase) 변환 + */ + +import type { SalesStatusApiResponse, PurchaseStatusApiResponse } from '../types'; +import type { SalesStatusData, PurchaseStatusData } from '@/components/business/CEODashboard/types'; + +const STATUS_MAP_SALES: Record = { + deposited: '입금완료', + unpaid: '미입금', + partial: '부분입금', +}; + +const STATUS_MAP_PURCHASE: Record = { + paid: '결제완료', + unpaid: '미결제', + partial: '부분결제', +}; + +/** + * Sales Summary API 응답 → Frontend SalesStatusData 변환 + */ +export function transformSalesStatusResponse(api: SalesStatusApiResponse): SalesStatusData { + return { + cumulativeSales: api.cumulative_sales, + achievementRate: api.achievement_rate, + yoyChange: api.yoy_change, + monthlySales: api.monthly_sales, + monthlyTrend: api.monthly_trend.map((item) => ({ + month: item.label, + amount: item.amount, + })), + clientSales: api.client_sales.map((item) => ({ + name: item.name, + amount: item.amount, + })), + dailyItems: api.daily_items.map((item) => ({ + date: item.date, + client: item.client, + item: item.item, + amount: item.amount, + status: STATUS_MAP_SALES[item.status] ?? '미입금', + })), + dailyTotal: api.daily_total, + }; +} + +/** + * Purchase Summary API 응답 → Frontend PurchaseStatusData 변환 + */ +export function transformPurchaseStatusResponse(api: PurchaseStatusApiResponse): PurchaseStatusData { + return { + cumulativePurchase: api.cumulative_purchase, + unpaidAmount: api.unpaid_amount, + yoyChange: api.yoy_change, + monthlyTrend: api.monthly_trend.map((item) => ({ + month: item.label, + amount: item.amount, + })), + materialRatio: api.material_ratio.map((item) => ({ + name: item.name, + value: item.value, + percentage: item.percentage, + color: item.color, + })), + dailyItems: api.daily_items.map((item) => ({ + date: item.date, + supplier: item.supplier, + item: item.item, + amount: item.amount, + status: STATUS_MAP_PURCHASE[item.status] ?? '미결제', + })), + dailyTotal: api.daily_total, + }; +} diff --git a/src/lib/api/dashboard/transformers/status-issue.ts b/src/lib/api/dashboard/transformers/status-issue.ts index 7216ee6e..d1192839 100644 --- a/src/lib/api/dashboard/transformers/status-issue.ts +++ b/src/lib/api/dashboard/transformers/status-issue.ts @@ -24,7 +24,7 @@ const STATUS_BOARD_FALLBACK_SUB_LABELS: Record = { tax_deadline: '', new_clients: '대한철강 외', leaves: '', - purchases: '(유)한국정밀 외', + // purchases: '(유)한국정밀 외', // [2026-03-03] 비활성화 — 백엔드 path 오류 + 데이터 정합성 이슈 (N4 참조) approvals: '구매 결재 외', }; @@ -52,19 +52,42 @@ function buildStatusSubLabel(item: { id: string; count: number | string; sub_lab return fallback.replace(/ 외$/, ''); } +/** + * 프론트 path 오버라이드: 백엔드 path가 잘못되거나 링크 불필요한 항목 + * - 값이 빈 문자열: 클릭 비활성화 (일정 표시 등 링크 불필요) + * - 값이 경로 문자열: 백엔드 path 대신 사용 + */ +const STATUS_BOARD_PATH_OVERRIDE: Record = { + tax_deadline: '/accounting/tax-invoices', // 백엔드 /accounting/tax → 실제 페이지 + // purchases: '/accounting/purchase', // [2026-03-03] 비활성화 — purchases 항목 자체를 숨김 (N4 참조) +}; + +/** + * [2026-03-03] 비활성화 항목: 백엔드 이슈 해결 전까지 현황판에서 숨김 + * - purchases: path 오류(건설경로 하드코딩) + 데이터 정합성 미확인 (API-SPEC N4 참조) + */ +const STATUS_BOARD_HIDDEN_ITEMS = new Set(['purchases']); + /** * StatusBoard API 응답 → Frontend 타입 변환 * API 응답 형식이 TodayIssueItem과 거의 동일하므로 단순 매핑 */ export function transformStatusBoardResponse(api: StatusBoardApiResponse): TodayIssueItem[] { - return api.items.map((item) => ({ - id: item.id, - label: item.label, - count: item.count, - subLabel: buildStatusSubLabel(item), - path: normalizePath(item.path, { addViewMode: true }), - isHighlighted: item.isHighlighted, - })); + return api.items.filter((item) => !STATUS_BOARD_HIDDEN_ITEMS.has(item.id)).map((item) => { + const overridePath = STATUS_BOARD_PATH_OVERRIDE[item.id]; + const path = overridePath !== undefined + ? overridePath + : normalizePath(item.path, { addViewMode: true }); + + return { + id: item.id, + label: item.label, + count: item.count, + subLabel: buildStatusSubLabel(item), + path, + isHighlighted: item.isHighlighted, + }; + }); } // ============================================ diff --git a/src/lib/api/dashboard/types.ts b/src/lib/api/dashboard/types.ts index ad3839da..e9257396 100644 --- a/src/lib/api/dashboard/types.ts +++ b/src/lib/api/dashboard/types.ts @@ -40,7 +40,11 @@ export interface DailyReportApiResponse { monthly_operating_expense: number; // 월 운영비 (직전 3개월 평균) operating_months: number | null; // 운영 가능 개월 수 operating_stability: OperatingStability; // 안정성 상태 - // 어제 대비 변동률 (optional - 백엔드에서 제공 시) + // 기획서 D1.7 자금현황 카드용 필드 + receivable_balance: number; // 미수금 잔액 + payable_balance: number; // 미지급금 잔액 + monthly_expense_total: number; // 당월 예상 지출 합계 + // 어제 대비 변동률 (optional - 백엔드에서 제공 시, 현재 주석 처리) daily_change?: DailyChangeRate; } @@ -706,4 +710,198 @@ export interface TaxSimulationApiResponse { loan_summary: TaxSimulationLoanSummaryApiResponse; // 가지급금 요약 corporate_tax: CorporateTaxComparisonApiResponse; // 법인세 비교 income_tax: IncomeTaxComparisonApiResponse; // 소득세 비교 +} + +// ============================================ +// 19. SalesStatus (매출 현황) API 응답 타입 +// ============================================ + +/** 매출 월별 추이 */ +export interface SalesMonthlyTrendApiResponse { + month: string; // "2026-08" + label: string; // "8월" + amount: number; // 금액 +} + +/** 거래처별 매출 */ +export interface SalesClientApiResponse { + name: string; // 거래처명 + amount: number; // 금액 +} + +/** 일별 매출 아이템 */ +export interface SalesDailyItemApiResponse { + date: string; // "2026-02-01" + client: string; // 거래처명 + item: string; // 품목명 + amount: number; // 금액 + status: 'deposited' | 'unpaid' | 'partial'; // 입금상태 +} + +/** GET /api/v1/dashboard/sales/summary 응답 */ +export interface SalesStatusApiResponse { + cumulative_sales: number; // 누적 매출 + achievement_rate: number; // 달성률 (%) + yoy_change: number; // 전년 동월 대비 변화율 (%) + monthly_sales: number; // 당월 매출 + monthly_trend: SalesMonthlyTrendApiResponse[]; + client_sales: SalesClientApiResponse[]; + daily_items: SalesDailyItemApiResponse[]; + daily_total: number; // 일별 합계 +} + +// ============================================ +// 20. PurchaseStatus (매입 현황) API 응답 타입 +// ============================================ + +/** 매입 월별 추이 */ +export interface PurchaseMonthlyTrendDashboardApiResponse { + month: string; // "2026-08" + label: string; // "8월" + amount: number; // 금액 +} + +/** 자재 구성 비율 */ +export interface PurchaseMaterialRatioApiResponse { + name: string; // "원자재", "부자재", "소모품" + value: number; // 금액 + percentage: number; // 비율 (%) + color: string; // 차트 색상 +} + +/** 일별 매입 아이템 */ +export interface PurchaseDailyItemApiResponse { + date: string; // "2026-02-01" + supplier: string; // 거래처명 + item: string; // 품목명 + amount: number; // 금액 + status: 'paid' | 'unpaid' | 'partial'; // 결제상태 +} + +/** GET /api/v1/dashboard/purchases/summary 응답 */ +export interface PurchaseStatusApiResponse { + cumulative_purchase: number; // 누적 매입 + unpaid_amount: number; // 미결제 금액 + yoy_change: number; // 전년 동월 대비 변화율 (%) + monthly_trend: PurchaseMonthlyTrendDashboardApiResponse[]; + material_ratio: PurchaseMaterialRatioApiResponse[]; + daily_items: PurchaseDailyItemApiResponse[]; + daily_total: number; // 일별 합계 +} + +// ============================================ +// 21. DailyProduction (생산 현황) API 응답 타입 +// ============================================ + +/** 공정별 작업 아이템 */ +export interface ProductionWorkItemApiResponse { + id: string; + order_no: string; // 수주번호 + client: string; // 거래처명 + product: string; // 제품명 + quantity: number; // 수량 + status: 'in_progress' | 'pending' | 'completed'; +} + +/** 공정별 작업자 */ +export interface ProductionWorkerApiResponse { + name: string; + assigned: number; // 배정 건수 + completed: number; // 완료 건수 + rate: number; // 완료율 (%) +} + +/** 공정별 데이터 */ +export interface ProductionProcessApiResponse { + process_name: string; // "스크린", "슬랫", "절곡" + total_work: number; + todo: number; + in_progress: number; + completed: number; + urgent: number; // 긴급 건수 + sub_line: number; + regular: number; + worker_count: number; + work_items: ProductionWorkItemApiResponse[]; + workers: ProductionWorkerApiResponse[]; +} + +/** 출고 현황 */ +export interface ShipmentApiResponse { + expected_amount: number; + expected_count: number; + actual_amount: number; + actual_count: number; +} + +/** GET /api/v1/dashboard/production/summary 응답 */ +export interface DailyProductionApiResponse { + date: string; // "2026-02-23" + day_of_week: string; // "월요일" + processes: ProductionProcessApiResponse[]; + shipment: ShipmentApiResponse; +} + +// ============================================ +// 22. Unshipped (미출고 내역) API 응답 타입 +// ============================================ + +/** 미출고 아이템 */ +export interface UnshippedItemApiResponse { + id: string; + port_no: string; // 로트번호 + site_name: string; // 현장명 + order_client: string; // 수주처 + due_date: string; // 납기일 + days_left: number; // 잔여일수 +} + +/** GET /api/v1/dashboard/unshipped/summary 응답 */ +export interface UnshippedApiResponse { + items: UnshippedItemApiResponse[]; + total_count: number; +} + +// ============================================ +// 23. Construction (시공 현황) API 응답 타입 +// ============================================ + +/** 시공 아이템 */ +export interface ConstructionItemApiResponse { + id: string; + site_name: string; // 현장명 + client: string; // 거래처명 + start_date: string; // 시작일 + end_date: string; // 종료일 + progress: number; // 진행률 (%) + status: 'in_progress' | 'scheduled' | 'completed'; +} + +/** GET /api/v1/dashboard/construction/summary 응답 */ +export interface ConstructionApiResponse { + this_month: number; // 이번 달 시공 건수 + completed: number; // 완료 건수 + items: ConstructionItemApiResponse[]; +} + +// ============================================ +// 24. DailyAttendance (근태 현황) API 응답 타입 +// ============================================ + +/** 직원 근태 아이템 */ +export interface AttendanceEmployeeApiResponse { + id: string; + department: string; // 부서명 + position: string; // 직급 + name: string; // 이름 + status: 'present' | 'on_leave' | 'late' | 'absent'; +} + +/** GET /api/v1/dashboard/attendance/summary 응답 */ +export interface DailyAttendanceApiResponse { + present: number; // 출근 + on_leave: number; // 휴가 + late: number; // 지각 + absent: number; // 결근 + employees: AttendanceEmployeeApiResponse[]; } \ No newline at end of file