7 Commits

Author SHA1 Message Date
7bd4bd38da feat: [quality] 수주처 선택 UI + client_id 연동 + 수정 저장 개선
- 수주처를 텍스트 입력에서 거래처 검색 선택으로 변경
- 수주 선택 시 거래처+모델 필터 연동 (양방향)
- ProductInspection/Api에 clientId 매핑 추가
- 수정 시 새 개소 locations 필터 (NaN ID 에러 해결)
- SupplierSearchModal 콜백에 id 반환 추가
2026-03-09 21:06:57 +09:00
유병철
68331be0ef feat: 회계/결재/생산/출하/대시보드 다수 개선 및 QA 수정
- BadDebtCollection, BillManagement, CardTransaction, TaxInvoice 회계 개선
- VendorManagement/VendorDetailClient 소폭 추가
- DocumentCreate/DraftBox 결재 기능 개선
- WorkOrder Create/Detail/Edit, ShipmentEdit 생산/출하 개선
- CEO 대시보드: PurchaseStatusSection, receivable/status-issue transformer 정비
- dashboard types/invalidation 확장
- LoginPage, Sidebar, HeaderFavoritesBar 레이아웃 수정
- QMS 페이지, StockStatusDetail, OrderRegistration 소폭 수정
- AttendanceManagement, VacationManagement HR 수정
- ConstructionDetailClient 건설 상세 개선
- claudedocs: 주간 구현내역, 대시보드 QA/수정계획, 결재/품질/생산/출하 문서 추가
2026-03-09 21:06:01 +09:00
유병철
7d369d1404 feat: 계정과목 공통화 및 회계 모듈 전반 개선
- 계정과목 관리를 accounting/common/으로 통합 (AccountSubjectSettingModal 이동)
- GeneralJournalEntry: 계정과목 actions/types 분리, 모달 import 경로 변경
- CardTransactionInquiry: JournalEntryModal/ManualInputModal 개선
- TaxInvoiceManagement: actions/types 리팩토링
- DepositManagement/WithdrawalManagement: 소폭 개선
- ExpectedExpenseManagement: UI 개선
- GiftCertificateManagement: 상세/목록 개선
- BillManagement: BillDetail/Client/index 소폭 추가
- PurchaseManagement/SalesManagement: 상세뷰 개선
- CEO 대시보드: dashboard-invalidation 유틸 추가, useCEODashboard 확장
- OrderRegistration/OrderSalesDetailView 소폭 수정
- claudedocs: 계정과목 통합 계획/분석/체크리스트, 대시보드 검증 문서 추가
2026-03-08 12:44:36 +09:00
74e0e2bf44 fix: InspectionManagement 타입 에러 일괄 수정
- ProductInspectionApi order_items에 document_id, inspection_data 속성 추가
- saveLocationInspection 파라미터를 ProductInspectionData 타입으로 변경
- inspection_data API→Frontend 변환 시 타입 캐스팅 수정
- 로컬 빌드 성공 확인
2026-03-07 02:05:17 +09:00
c94236e15c fix: ProductInspectionApi order_items에 누락된 속성 추가
- document_id, inspection_data 속성 추가
- 빌드 타입 에러 해결
2026-03-07 01:56:30 +09:00
3bade70c5f fix: ProductInspectionData 타입 에러 수정
- saveLocationInspection 파라미터를 Record<string, unknown>에서 ProductInspectionData로 변경
- interface는 index signature가 없어 Record<string, unknown>에 할당 불가
2026-03-07 01:49:38 +09:00
b7c2b99c68 fix: ApiBomItem에 없는 specification 속성 참조 제거
- item.specification fallback 제거 (ApiBomItem에 spec만 존재)
- 빌드 타입 에러 해결
2026-03-07 01:38:23 +09:00
93 changed files with 5208 additions and 680 deletions

View File

@@ -0,0 +1,123 @@
# 계정과목 통합 프로젝트 체크리스트
> 시작: 2026-03-06
> 목표: 계정과목 마스터 통합 → 분개 흐름 통합 → 대시보드 연동
---
## Phase 1: 계정과목 마스터 강화 (백엔드)
### 1-1. account_codes 테이블 확장
- [x] 마이그레이션: sub_category(중분류), depth(계층), parent_code(상위계정), department_type(부문) 추가
- [x] AccountCode 모델 업데이트 (fillable, casts, 관계)
- [x] AccountCodeService 확장 (계층 조회, 부문 필터 지원)
- [x] AccountSubjectController 확장 (새 필드 지원 API)
- [x] UpdateAccountSubjectRequest 생성
- [x] 라우트 추가 (PUT /{id}, POST /seed-defaults)
### 1-2. 표준 계정과목표 시드 데이터 (더존 Smart A 기준)
- [x] 시드 데이터 정의 (대분류 5개 + 중분류 12개 + 소분류 111개 = 128건)
- [x] seedDefaults() API 엔드포인트 (별도 Seeder 대신 API로 제공)
- [x] 기존 데이터와 충돌 방지 로직 (tenant_id+code 중복 시 skip)
---
## Phase 2: 프론트 공용 컴포넌트
### 2-1. 공용 계정과목 설정 모달 (리스트 페이지용 - CRUD)
- [x] AccountSubjectSettingModal 공용 컴포넌트 생성 (src/components/accounting/common/)
- [x] 기존 GeneralJournalEntry/AccountSubjectSettingModal 코드 이관 + 확장
- [x] 계층 표시 (depth별 들여쓰기: 대→중→소)
- [x] 부문 컬럼 추가
- [x] "기본 계정과목 생성" 버튼 (seedDefaults API 연동)
### 2-2. 공용 계정과목 Select (세부 페이지/모달용 - 조회/선택)
- [x] AccountSubjectSelect 공용 컴포넌트 생성
- [x] DB 마스터 API 호출로 옵션 로드 (selectable=true, isActive=true)
- [x] 활성 계정과목만 표시
- [x] "[코드] 계정과목명" 형태 표시 (예: [51100] 복리후생비(제조))
- [x] 분류별 필터 지원 (props: category, subCategory, departmentType)
### 2-3. 공용 타입/API 함수
- [x] 공용 타입 정의 (src/components/accounting/common/types.ts)
- [x] 공용 actions.ts (계정과목 CRUD + seedDefaults + update API)
- [x] index.ts 배럴 파일 생성
---
## Phase 3: 7개 모듈 전환 (프론트)
### 3-1. 일반전표입력
- [x] 전용 AccountSubjectSettingModal → 공용 컴포넌트로 교체
- [x] 전용 타입/API → 공용으로 교체 (actions.ts, types.ts 정리)
- [x] ManualJournalEntryModal: getAccountSubjects → 공용 actions
- [x] JournalEditModal: getAccountSubjects → 공용 actions
- [x] 전용 AccountSubjectSettingModal.tsx 삭제
### 3-2. 세금계산서관리
- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
### 3-3. 카드사용내역
- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
- [x] ManualInputModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
- [x] index.tsx 인라인 Select → AccountSubjectSelect
- 참고: ACCOUNT_SUBJECT_OPTIONS 상수는 엑셀 변환에서 기존 데이터 호환용으로 유지
### 3-4. 입금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님)
### 3-5. 출금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님)
### 3-6. 미지급비용
- [x] ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect (category="expense" 필터)
### 3-7. 매출관리 — 보류 (매출유형 분류이며 계정과목 코드가 아님)
---
## Phase 4: 분개 흐름 통합 (백엔드)
### 4-1. source_type 확장
- [x] JournalEntry 모델에 SOURCE_CARD_TRANSACTION, SOURCE_TAX_INVOICE 상수 추가
- [x] source_type은 string(30)이므로 enum 마이그레이션 불필요 (상수 추가만으로 완료)
### 4-2. 세금계산서 분개 통합
- [x] JournalSyncService 생성 (공용 분개 CRUD + expense 동기화)
- [x] TaxInvoiceController에 journal CRUD 메서드 추가 (get/store/delete)
- [x] 라우트 추가: GET/POST/PUT/DELETE /api/v1/tax-invoices/{id}/journal-entries
- [x] source_type = 'tax_invoice', source_key = 'tax_invoice_{id}'
### 4-3. 카드사용내역 분개 통합
- [x] CardTransactionController에 journal CRUD 메서드 추가 (get/store)
- [x] 라우트 추가: GET/POST /api/v1/card-transactions/{id}/journal-entries
- [x] 카드 items → 차변(비용계정) + 대변(미지급금) 자동 변환
- [x] source_type = 'card_transaction', source_key = 'card_{id}'
---
## Phase 5: 대시보드 연동
### 5-1. expense_accounts 동기화 확장
- [x] SyncsExpenseAccounts 트레이트 생성 (app/Traits/)
- [x] GeneralJournalEntryService → 트레이트 사용으로 전환
- [x] JournalSyncService에서 트레이트 사용 (세금계산서/카드 분개 저장 시 자동 동기화)
- [x] source_type별 payment_method 자동 결정 (card_transaction → PAYMENT_CARD)
- [x] 모든 source_type에서 복리후생비/접대비 감지
### 5-2. 대시보드 집계 검증
- [x] expense_accounts에 journal_entry_id/journal_entry_line_id 연결 (기존 마이그레이션 활용)
- [x] CEO 대시보드는 expense_accounts 테이블 기준 집계 → 모든 source_type 반영됨
---
## 작업 순서 및 의존성
```
Phase 1 (백엔드 마스터 강화)
Phase 2 (프론트 공용 컴포넌트)
Phase 3 (7개 모듈 전환) — 모듈별 독립, 병렬 가능
Phase 4 (분개 흐름 통합) — Phase 3과 병렬 가능
Phase 5 (대시보드 연동)
```

View File

@@ -0,0 +1,250 @@
# 프론트엔드 주간 구현내역 (2026-03-02 ~ 2026-03-08)
> 총 커밋 59개 (feat 30 / fix 17 / refactor 3 / chore 3 / merge 1 / 기타 5)
---
## 1. 품질관리 — Mock→API 전환 + 검사 모달/문서 대폭 개선
**커밋**: 50e4c72c, 563b240f, 899493a7, fe930b58, e7263fee, 4ea03922, 295585d8, c150d807, e75d8f9b (9개)
**변경 규모**: +2,210 / -566 라인
### 1-1. API 전환
- `USE_MOCK_FALLBACK = false` 설정, Mock 데이터 제거
- 엔드포인트: `/api/v1/quality/documents`, `/api/v1/quality/performance-reports`
- snake_case → camelCase 변환 함수 구현
- InspectionFormData에 `clientId`, `inspectorId`, `receptionDate` 필드 추가
### 1-2. 검사 모달 개선 (InspectionInputModal)
- 일괄 합격/초기화 토글 버튼 추가
- 시공 치수 필드 (너비/높이) 추가
- 변경사유 입력 필드 추가
- 사진 첨부 (최대 2장, base64)
- 이전/다음 개소 네비게이션 + 자동저장
- 레거시 검사 데이터 통합 (합격/불합격/진행중/미완)
### 1-3. 수주선택 모달 (OrderSelectModal)
- 발주처(clientName) 컬럼 추가
- 동일 발주처 + 동일 모델 필터링 제약
- `SearchableSelectionModal``isItemDisabled` 콜백 추가 (공통 컴포넌트 확장)
- 비활성 항목 스타일링 + 전체선택 시 비활성 항목 제외
### 1-4. 제품검사 성적서 (FqcDocumentContent)
- 8컬럼 동적 렌더링: No / 검사항목 / 세부항목 / 검사기준 / 검사방법 / 검사주기 / 측정값 / 판정
- rowSpan 병합: 카테고리 단일 + method+frequency 복합 병합
- measurement_type별 처리: checkbox → 양호/불량, numeric → 숫자입력, none → 비활성
- FQC 모드 우선 + legacy fallback 패턴
### 1-5. 제품검사 요청서 (FqcRequestDocumentContent) — 신규
- 양식 기반 동적 렌더링 (template_id: 66)
- 결재라인 + 기본정보(7개) + 입력섹션(4개) + 사전통보 테이블
- EAV 데이터 구조: section_id, column_id, row_index, field_key, field_value
- EAV 문서 없을 때 legacy fallback 적용
### 1-6. 수주 연결 동기화
- order_ids 배열 매핑 (다중 수주 지원)
- 개소별 inspectionData 서버 저장
### 주요 파일
- `src/components/quality/InspectionManagement/actions.ts`
- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx`
- `src/components/quality/InspectionManagement/OrderSelectModal.tsx`
- `src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx` (신규)
- `src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx` (신규)
- `src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx`
---
## 2. 문서스냅샷 시스템 (Lazy Snapshot) — 신규 기능
**커밋**: 31f523c8, a1fb0d4f, 8250eaf2, 72a2a3e9, 04f2a8a7 (5개)
**변경 규모**: +300 라인
### 개요
문서 저장/조회 시 `rendered_html` 스냅샷을 자동 캡처하여 백엔드에 전송하는 시스템.
### 2-1. 수동 캡처 (저장 시)
- 검사성적서(InspectionReportModal): `contentWrapperRef.innerHTML` 캡처 → 저장 시 `rendered_html` 파라미터 포함
- 작업일지(WorkLogModal): 동일 패턴
- 수입검사(ImportInspectionInputModal): 오프스크린 렌더링 방식
### 2-2. Lazy Snapshot (조회 시 자동 캡처)
- 조건: `rendered_html === NULL`인 문서 조회 시
- 동작: 500ms 지연 → innerHTML 캡처 → 백그라운드 PATCH
- 비차단(non-blocking): UI에 영향 없이 백그라운드 처리
- `patchDocumentSnapshot()` 서버 액션으로 전송
### 2-3. 오프스크린 렌더링 유틸리티
- `src/lib/utils/capture-rendered-html.tsx` (신규)
- 폼 HTML이 아닌 실제 문서 렌더링 결과를 캡처
- readOnly 모드 자동 캡처 useEffect 제거 (불필요한 PUT 요청 방지)
### 적용 범위
| 문서 | 수동 캡처 | Lazy Snapshot |
|------|-----------|---------------|
| 검사성적서 | ✅ | ✅ |
| 작업일지 | ✅ | ✅ |
| 수입검사 | ✅ (오프스크린) | - |
| 제품검사 요청서 | ✅ | ✅ |
### 주요 파일
- `src/lib/utils/capture-rendered-html.tsx` (신규)
- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx`
- `src/components/production/WorkerScreen/WorkLogModal.tsx`
- `src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx`
- `src/components/production/WorkOrders/actions.ts`
---
## 3. 생산지시 — API 연동 + 작업자 화면 + 중간검사
**커밋**: fa7efb7b, 8b6da749, 4331b84a, 0166601b, 8bcabafd, 2a2a356f, b45c35a5, 0b81e9c1 (8개)
**변경 규모**: +2,000 라인
### 3-1. 생산지시 목록/상세 API 연동
- Mock → API 전환 (`executePaginatedAction` + `buildApiUrl` 패턴)
- 서버사이드 페이지네이션 + 동적 탭 카운트 (stats API)
- WorkOrder 상태 배지 6단계: 미배정→배정→작업중→검사→완료→출하
- BOM null 상태 처리
### 3-2. 절곡 중간검사 입력 모달 (InspectionInputModal)
- 7개 제품 항목 통합 폼
- 제품 ID 자동 매칭: 정규화 → 키워드 → 인덱스 fallback (3단계)
- cellValues 구조: `{bending_state, length, width, spacing}`
- PO 단위 데이터 공유 모델: 동일 PO 내 모든 아이템이 inspection_data 공유
### 3-3. 자재투입 모달 (MaterialInputModal)
- 동일 자재 다중 BOM 그룹 LOT 독립 관리
- `bomGroupKey = ${item_id}-${category}-${partType}` 그룹핑
- 카테고리 정렬: 가이드레일(1) → 하단마감재(2) → 셔터박스(3) → 연기차단재(4)
- FIFO 자동충전 시 그룹 간 물리적 LOT 가용량 추적
- 번호 배지(①②③) + partType 배지
### 3-4. 공정 단계 검사범위 설정 (InspectionScope) — 신규
- 전수검사 / 샘플링 / 그룹 3가지 타입
- 샘플링 시 샘플 수(n) 입력 지원
- StepForm 컴포넌트에 UI 추가, options JSON으로 API 저장
### 주요 파일
- `src/components/production/ProductionOrders/actions.ts`, `types.ts`
- `src/components/production/WorkerScreen/InspectionInputModal.tsx`
- `src/components/production/WorkerScreen/MaterialInputModal.tsx`
- `src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx` (신규)
- `src/components/process-management/StepForm.tsx`
- `src/types/process.ts`
---
## 4. 출하/배차 — 배차 다중행 + 차량관리 API + 출고관리
**커밋**: 83a23701, 1d380578, 5ff5093d, f653960a, a4f99ae3, 03d129c3 (6개)
**변경 규모**: +2,400 / -1,100 라인
### 4-1. 배차정보 다중 행 API 연동
- `vehicle_dispatches` 배열 지원 (기존 단일 배차 → 다중 배차)
- transform 함수: `transformApiToDetail`, `transformCreateFormToApi`, `transformEditFormToApi` 갱신
- 레거시 단일 배차 필드 하위호환 유지
### 4-2. 배차차량관리 Mock→API 전환
- `executePaginatedAction` + `buildApiUrl` 패턴 적용
- `transformToListItem()` / `transformToDetail()` snake_case → camelCase 변환
- 쿼리 파라미터: `search`, `status`, `start_date`, `end_date`, `page`, `per_page`
### 4-3. 출고관리 목록 필드 매핑
- `writer_name`, `writer_id`, `delivery_date` 등 5개 필드 API 매핑 추가
- `OrderInfoApiData` 타입으로 주문 연결 정보 처리
### 4-4. 배차 상세/수정 레이아웃 개선
- 기본정보 그리드: 1열 → 2×4열 레이아웃
### 4-5. 출하관리 캘린더
- 기본 뷰: day → week-time 변경
### 주요 파일
- `src/components/outbound/ShipmentManagement/actions.ts`
- `src/components/outbound/VehicleDispatchManagement/actions.ts`
- `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx`, `ShipmentEdit.tsx`
---
## 5. 전자결재 — 결재함 확장 + 연결문서
**커밋**: 181352d7, 72cf5d86 (2개)
**변경 규모**: +458 / -127 라인
### 5-1. 결재함 기능 확장
- 결재함 API 연동:
- `GET /api/v1/approvals/inbox` — 결재함 목록
- `GET /api/v1/approvals/inbox/summary` — 통계
- `POST /api/v1/approvals/{id}/approve` — 승인
- `POST /api/v1/approvals/{id}/reject` — 반려
- 문서 상태: DRAFT → PENDING → APPROVED / REJECTED / CANCELLED
### 5-2. 연결문서 기능 (LinkedDocumentContent) — 신규
- 검사성적서, 작업일지 등을 결재 문서에 연결하여 렌더링
- DocumentHeader 컴포넌트 활용, 결재라인/상태배지/메타 정보 표시
### 5-3. 모바일 반응형
- AuthenticatedLayout: 사이드바/메인 콘텐츠 모바일 대응
- HeaderFavoritesBar 전면 재설계
- SearchableSelectionModal HTML 유효성 수정
### 주요 파일
- `src/components/approval/ApprovalBox/actions.ts`, `index.tsx`, `types.ts`
- `src/components/approval/DocumentDetail/LinkedDocumentContent.tsx` (신규)
- `src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx`
- `src/layouts/AuthenticatedLayout.tsx`
- `src/components/layout/HeaderFavoritesBar.tsx`
---
## 6. CEO 대시보드 — API 연동 + 섹션 확장 + 리팩토링
**커밋**: 9ad4c8ee, 23fa9c0e, cde93336, 4e179d2e, db84d679, 1bccaffe, bec933b3, 1675f3ed (8개)
**별도 문서**: `claudedocs/dashboard/[VERIFY-2026-03-06] ceo-dashboard-data-flow-verification.md`
### 주요 변경
- SummaryNavBar 추가 (상단 요약 데이터 네비게이션)
- 접대비/복리후생비/매출채권/캘린더 섹션 개선
- 컴포넌트 분리 및 모달/섹션 리팩토링
- mockData/modalConfigs 정리
- API 연동 강화 (회계/결재/HR 섹션)
- `invalidateDashboard()` 시스템 추가 (5개 도메인 연동)
---
## 7. 회계 — 계정과목 공통화 + 어음 리팩토링
**커밋**: 7d369d14, 1691337f, a4f99ae3(일부) (3개)
**별도 문서**: `claudedocs/[IMPL-2026-03-06] account-subject-unification-checklist.md`
### 주요 변경
- AccountSubjectSelect 공통 컴포넌트: 7개 페이지에 일괄 적용
- 매출/매입/부실채권/일일보고 UI 개선
- BillManagement 섹션 분리: 11개 섹션 컴포넌트 + 커스텀 훅(`useBillForm`, `useBillConditions`)
---
## 8. 기타
### E2E 테스트
- `f5bdc5ba`: 11개 FAIL 시나리오 수정 후 전체 PASS
### 인프라
- `f9eea0c9`, `c18c68b6`: Slack 알림 채널 분리 (product_infra → deploy_react)
- `888fae11`: next dev에서 --turbo 플래그 제거
---
## 문서 현황
| 도메인 | 문서 상태 |
|--------|----------|
| 품질관리 Mock→API | ✅ 본 문서 §1 |
| 문서스냅샷 (Lazy Snapshot) | ✅ 본 문서 §2 |
| 생산지시 API 연동 | ✅ 본 문서 §3 |
| 출하/배차 API 연동 | ✅ 본 문서 §4 |
| 전자결재 확장 | ✅ 본 문서 §5 |
| CEO 대시보드 | ✅ 별도 문서 존재 |
| 계정과목 공통화 | ✅ 별도 문서 존재 |
| 백엔드 구현내역 | ✅ 일별 문서 존재 (03-02 ~ 03-08) |

View File

@@ -0,0 +1,498 @@
# 계정과목 통합 기획서
> 작성일: 2026-03-06
> 상태: 진행중
> 관련: `claudedocs/architecture/[ANALYSIS-2026-03-06] account-subject-comparison.md`
---
## 1. 배경 및 목표
### 문제점
현재 계정과목이 **7개 모듈에서 각자 하드코딩**으로 관리되고 있음.
- 일반전표만 DB 마스터(account_codes) 사용, 나머지는 프론트 상수 배열
- 계정과목 등록은 일반전표 설정에서만 가능
- 분개 데이터가 3개 테이블에 분산 (journal_entries, hometax_invoice_journals, barobill_card_transactions)
- CEO 대시보드 비용 집계가 일반전표 분개에서만 작동
### 목표
1. **계정과목 마스터 통합**: 하나의 DB 테이블, 전 모듈 공유
2. **공용 컴포넌트**: 설정 모달(CRUD) + Select(조회) 2개로 전 모듈 대응
3. **분개 흐름 통합**: 모든 분개 → journal_entries 한 곳에 저장
4. **대시보드 정확도**: 어디서 분개하든 비용 집계 정상 작동
### 회계담당자 요구사항
- 계정과목을 번호 + 명칭으로 구분 (예: 5201 급여)
- 제조/회계 동일 명칭이지만 번호로 구분 가능해야 함
- 등록하면 전체 공유, 개별 등록도 가능
---
## 2. 현재 상태 (AS-IS)
### 2.1 모듈별 계정과목 관리
| 모듈 | 소스 | 옵션 수 | 필드명 | API 필드 |
|------|------|---------|--------|----------|
| 일반전표입력 | DB 마스터 | 동적 | accountSubjectId | account_subject_id |
| 세금계산서관리 | 프론트 상수 | 11개 | accountSubject | account_subject |
| 카드사용내역 | 프론트 상수 | 16개 | accountSubject | account_code |
| 입금관리 | 프론트 상수 | ~11개 | depositType | account_code |
| 출금관리 | 프론트 상수 | ~11개 | withdrawalType | account_code |
| 미지급비용 | 프론트 상수 | 9개 | accountSubject | account_code |
| 매출관리 | 프론트 상수 | 8개 | accountSubject | account_code |
### 2.2 분개 저장 위치
| 소스 | 저장 테이블 | expense_accounts 동기화 |
|------|-----------|----------------------|
| 일반전표 (수기) | journal_entries + journal_entry_lines | O |
| 일반전표 (입출금 연동) | journal_entries + journal_entry_lines | O |
| 세금계산서 분개 | hometax_invoice_journals (별도) | X |
| 카드 계정과목 태그 | barobill_card_transactions.account_code | X |
### 2.3 백엔드 현재 테이블
```sql
-- account_codes (계정과목 마스터 - 일반전표만 사용)
id, tenant_id, code(10), name(100), category(enum), sort_order, is_active
-- journal_entries (분개 헤더)
id, tenant_id, entry_no, entry_date, entry_type, description,
total_debit, total_credit, status, source_type, source_key
-- journal_entry_lines (분개 상세)
id, journal_entry_id, tenant_id, line_no, account_code, account_name,
side(debit/credit), amount, trading_partner_id, trading_partner_name, description
-- hometax_invoice_journals (세금계산서 분개 - 별도)
id, tenant_id, hometax_invoice_id, nts_confirm_num,
dc_type, account_code, account_name, debit_amount, credit_amount, ...
-- barobill_card_transactions (카드 거래)
..., account_code, ...
```
---
## 3. 목표 상태 (TO-BE)
### 3.1 통합 구조
```
[계정과목 마스터]
account_codes 테이블 (확장)
├── code: "5201"
├── name: "급여"
├── category: "expense"
├── sub_category: "selling_admin" (판관비)
├── parent_code: "52" (상위 그룹)
├── depth: 3 (대=1, 중=2, 소=3)
└── department_type: "common" (공통/제조/관리)
[분개 통합]
journal_entries (source_type으로 출처 구분)
├── source_type: 'manual' ← 수기 전표
├── source_type: 'bank_transaction' ← 입출금 연동
├── source_type: 'tax_invoice' ← 세금계산서 (신규)
└── source_type: 'card_transaction' ← 카드사용내역 (신규)
[프론트 공용 컴포넌트]
AccountSubjectSettingModal → 리스트 페이지에서 CRUD
AccountSubjectSelect → 세부 페이지/모달에서 선택
```
### 3.2 데이터 흐름 (TO-BE)
```
계정과목 등록 (어느 페이지에서든)
→ account_codes 테이블에 저장
→ 전 모듈에서 즉시 사용 가능
분개 입력 (어느 모듈에서든)
→ journal_entries + journal_entry_lines에 저장
→ account_code는 account_codes 마스터 참조
→ expense_accounts 자동 동기화 (복리후생비/접대비)
→ CEO 대시보드에 자동 반영
```
---
## 4. Phase별 세부 구현 계획
### Phase 1: 백엔드 마스터 강화
#### 1-1. account_codes 테이블 확장 마이그레이션
```php
// database/migrations/2026_03_06_100000_enhance_account_codes_table.php
Schema::table('account_codes', function (Blueprint $table) {
$table->string('sub_category', 50)->nullable()->after('category')
->comment('중분류 (current_asset, fixed_asset, selling_admin, cogs 등)');
$table->string('parent_code', 10)->nullable()->after('sub_category')
->comment('상위 계정과목 코드 (계층 구조)');
$table->tinyInteger('depth')->default(3)->after('parent_code')
->comment('계층 깊이 (1=대분류, 2=중분류, 3=소분류)');
$table->string('department_type', 20)->default('common')->after('depth')
->comment('부문 (common=공통, manufacturing=제조, admin=관리)');
$table->string('description', 500)->nullable()->after('department_type')
->comment('계정과목 설명');
});
```
**sub_category 값 목록:**
| category | sub_category | 한글 |
|----------|-------------|------|
| asset | current_asset | 유동자산 |
| asset | fixed_asset | 비유동자산 |
| liability | current_liability | 유동부채 |
| liability | long_term_liability | 비유동부채 |
| capital | - | 자본 |
| revenue | sales_revenue | 매출 |
| revenue | other_revenue | 영업외수익 |
| expense | cogs | 매출원가 |
| expense | selling_admin | 판매비와관리비 |
| expense | other_expense | 영업외비용 |
**department_type 값:**
- `common`: 공통 (모든 부문에서 사용)
- `manufacturing`: 제조 (매출원가 계정)
- `admin`: 관리 (판관비 계정)
#### 1-2. AccountCode 모델 업데이트
```php
// app/Models/Tenants/AccountCode.php
protected $fillable = [
'tenant_id', 'code', 'name', 'category',
'sub_category', 'parent_code', 'depth', 'department_type',
'description', 'sort_order', 'is_active',
];
// 상수
const DEPT_COMMON = 'common';
const DEPT_MANUFACTURING = 'manufacturing';
const DEPT_ADMIN = 'admin';
const DEPTH_MAJOR = 1; // 대분류
const DEPTH_MIDDLE = 2; // 중분류
const DEPTH_MINOR = 3; // 소분류
```
#### 1-3. AccountCodeService 확장
기존 CRUD에 추가:
- `getHierarchical()`: 계층 구조 조회 (대-중-소 트리)
- `getByCategory(category, sub_category?)`: 분류별 조회
- `getByDepartment(department_type)`: 부문별 조회
- 필터: category, sub_category, department_type, depth, search, is_active
#### 1-4. AccountSubjectController 확장
기존 엔드포인트 유지 + 확장:
```
GET /api/v1/account-subjects ← 기존 (필터 파라미터 확장)
?category=expense
&sub_category=selling_admin
&department_type=common
&depth=3
&search=급여
&is_active=true
&hierarchical=true ← 계층 구조 응답 옵션
POST /api/v1/account-subjects ← 기존 (새 필드 추가)
PATCH /api/v1/account-subjects/{id} ← 신규 (수정)
PATCH /api/v1/account-subjects/{id}/status ← 기존
DELETE /api/v1/account-subjects/{id} ← 기존
POST /api/v1/account-subjects/seed-defaults ← 신규 (기본 계정과목표 일괄 생성)
```
#### 1-5. 표준 계정과목표 시드 데이터
```
1xxx 자산
11xx 유동자산
1101 현금
1102 보통예금
1103 당좌예금
1110 매출채권(외상매출금)
1120 선급금
1130 미수금
1140 가지급금
12xx 비유동자산
1201 토지
1202 건물
1210 기계장치
1220 차량운반구
1230 비품
1240 보증금
2xxx 부채
21xx 유동부채
2101 매입채무(외상매입금)
2102 미지급금
2103 선수금
2104 예수금
2110 부가세예수금
2120 부가세대급금
22xx 비유동부채
2201 장기차입금
3xxx 자본
31xx 자본금
3101 자본금
32xx 잉여금
3201 이익잉여금
4xxx 수익
41xx 매출
4101 제품매출
4102 상품매출
4103 부품매출
4104 용역매출
4105 공사매출
4106 임대수익
42xx 영업외수익
4201 이자수익
4202 외환차익
5xxx 비용
51xx 매출원가 (제조)
5101 재료비 ← department: manufacturing
5102 노무비 ← department: manufacturing
5103 외주가공비 ← department: manufacturing
52xx 판매비와관리비 (관리)
5201 급여 ← department: admin
5202 복리후생비 ← department: admin
5203 접대비 ← department: admin
5204 세금과공과 ← department: admin
5205 감가상각비 ← department: admin
5206 임차료 ← department: admin
5207 보험료(4대보험) ← department: admin
5208 통신비 ← department: admin
5209 수도광열비 ← department: admin
5210 소모품비 ← department: admin
5211 여비교통비 ← department: admin
5212 차량유지비 ← department: admin
5213 운반비 ← department: admin
5214 재료비 ← department: admin (관리부문)
5220 경비 ← department: admin
53xx 영업외비용
5301 이자비용
5302 외환차손
5310 배당금지급
```
기존 하드코딩 옵션과의 매핑:
| 기존 하드코딩 (영문 키워드) | 매핑될 계정코드 |
|---------------------------|---------------|
| purchasePayment (매입대금) | 2101 매입채무 |
| advance (선급금) | 1120 선급금 |
| suspense (가지급금) | 1140 가지급금 |
| rent (임차료) | 5206 임차료 |
| salary (급여) | 5201 급여 |
| insurance (4대보험) | 5207 보험료 |
| tax (세금) | 5204 세금과공과 |
| utilities (공과금) | 5209 수도광열비 |
| expenses (경비) | 5220 경비 |
| salesRevenue (매출수금) | 4101~4106 매출 |
| accountsReceivable (외상매출금) | 1110 매출채권 |
| accountsPayable (외상매입금) | 2101 매입채무 |
| salesVat (부가세예수금) | 2110 부가세예수금 |
| purchaseVat (부가세대급금) | 2120 부가세대급금 |
| cashAndDeposits (현금및예금) | 1101~1103 현금/예금 |
| advanceReceived (선수금) | 2103 선수금 |
---
### Phase 2: 프론트 공용 컴포넌트
#### 2-1. 파일 구조
```
src/components/accounting/common/
├── types.ts # 공용 타입 정의
├── actions.ts # 공용 계정과목 API 함수
├── AccountSubjectSettingModal.tsx # 설정 모달 (CRUD)
└── AccountSubjectSelect.tsx # Select 컴포넌트 (조회/선택)
```
#### 2-2. 공용 타입 (types.ts)
```typescript
export interface AccountSubject {
id: string;
code: string; // "5201"
name: string; // "급여"
category: AccountCategory; // 'asset' | 'liability' | 'capital' | 'revenue' | 'expense'
subCategory: string | null;
parentCode: string | null;
depth: number; // 1=대, 2=중, 3=소
departmentType: string; // 'common' | 'manufacturing' | 'admin'
description: string | null;
isActive: boolean;
}
// Select에서 표시할 때: `[${code}] ${name}` → "[5201] 급여"
```
#### 2-3. 공용 actions.ts
```typescript
'use server';
// 계정과목 조회 (Select용 - 활성만)
export async function getAccountSubjects(params?)
// 계정과목 CRUD (설정 모달용)
export async function createAccountSubject(data)
export async function updateAccountSubject(id, data)
export async function updateAccountSubjectStatus(id, isActive)
export async function deleteAccountSubject(id)
// 기본 계정과목표 일괄 생성
export async function seedDefaultAccountSubjects()
```
#### 2-4. AccountSubjectSettingModal (설정 모달)
기존 GeneralJournalEntry/AccountSubjectSettingModal 기반 확장:
- 계층 구조 표시 (번호대별 그룹핑 또는 들여쓰기)
- 대분류/중분류/부문 필터
- 등록: 코드 + 명칭 + 분류 + 중분류 + 부문
- 수정: 명칭, 분류, 상태
- 삭제: 미사용 계정만
- "기본 계정과목표 불러오기" 버튼 (초기 세팅용)
#### 2-5. AccountSubjectSelect (Select 컴포넌트)
```typescript
interface AccountSubjectSelectProps {
value: string; // 선택된 계정과목 code
onValueChange: (code: string) => void;
category?: AccountCategory; // 특정 분류만 표시
subCategory?: string; // 특정 중분류만 표시
departmentType?: string; // 특정 부문만 표시
placeholder?: string;
disabled?: boolean;
className?: string;
size?: 'default' | 'sm';
}
```
사용 예시:
```tsx
// 세금계산서 분개 - 전체 계정과목
<AccountSubjectSelect value={row.accountCode} onValueChange={...} />
// 카드내역 - 비용 계정만
<AccountSubjectSelect value={...} onValueChange={...} category="expense" />
// 입금관리 - 수익 + 자산 계정
<AccountSubjectSelect value={...} onValueChange={...} />
```
---
### Phase 3: 7개 모듈 전환
각 모듈에서:
1. 하드코딩 ACCOUNT_SUBJECT_OPTIONS 상수 **제거**
2. Radix Select → **AccountSubjectSelect** 교체
3. 리스트 페이지에 **설정 모달 버튼** 추가 (필요한 곳만)
4. API 저장 시 영문 키워드 → **계정코드(숫자)** 로 변경
#### 데이터 마이그레이션 고려
기존 데이터의 영문 키워드를 숫자 코드로 변환하는 마이그레이션 필요:
```php
// 예: barobill_card_transactions.account_code
// 'salary' → '5201'
// 'rent' → '5206'
```
---
### Phase 4: 분개 흐름 통합
#### 4-1. JournalEntry source_type 확장
```php
// JournalEntry 모델
const SOURCE_MANUAL = 'manual';
const SOURCE_BANK_TRANSACTION = 'bank_transaction';
const SOURCE_TAX_INVOICE = 'tax_invoice'; // 신규
const SOURCE_CARD_TRANSACTION = 'card_transaction'; // 신규
```
#### 4-2. 세금계산서 분개 통합
현재: `/api/v1/tax-invoices/{id}/journal-entries` → hometax_invoice_journals 저장
변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장
- source_type = 'tax_invoice'
- source_key = 'tax_invoice_{id}'
- hometax_invoice_journals는 레거시 호환으로 유지 (향후 제거)
#### 4-3. 카드사용내역 분개 통합
현재: `/api/v1/card-transactions/{id}/journal-entries` → barobill_card_transaction_splits
변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장
- source_type = 'card_transaction'
- source_key = 'card_{id}'
---
### Phase 5: 대시보드 연동
#### 5-1. expense_accounts 동기화 공용화
현재 GeneralJournalEntryService에만 있는 syncExpenseAccounts를:
- **JournalEntryService (공용)** 로 분리
- 모든 분개 저장/수정/삭제 시 자동 호출
- account_name에 '복리후생비' 또는 '접대비' 포함 → expense_accounts 동기화
#### 5-2. 검증
- 일반전표에서 복리후생비 분개 → 대시보드 반영 확인
- 세금계산서에서 복리후생비 분개 → 대시보드 반영 확인
- 카드내역에서 복리후생비 분개 → 대시보드 반영 확인
---
## 5. 작업 순서 및 의존성
```
Phase 1: 백엔드 마스터 강화
├── 1-1. 마이그레이션 + 모델
├── 1-2. 서비스 + 컨트롤러
└── 1-3. 시드 데이터
Phase 2: 프론트 공용 컴포넌트
├── 2-1. 공용 타입 + actions
├── 2-2. AccountSubjectSettingModal
└── 2-3. AccountSubjectSelect
Phase 3: 7개 모듈 전환 ──────────── Phase 4: 분개 흐름 통합
├── 3-1. 일반전표 ├── 4-1. source_type 확장
├── 3-2. 세금계산서 ├── 4-2. 세금계산서 분개
├── 3-3. 카드사용내역 └── 4-3. 카드 분개
├── 3-4. 입금관리 ↓
├── 3-5. 출금관리 Phase 5: 대시보드 연동
├── 3-6. 미지급비용 ├── 5-1. 동기화 공용화
└── 3-7. 매출관리 └── 5-2. 검증
```
---
## 6. 리스크 및 주의사항
| 리스크 | 대응 |
|--------|------|
| 기존 데이터 마이그레이션 | 영문 키워드 → 숫자 코드 변환 마이그레이션 작성 |
| 하드코딩 의존 코드 | 엑셀 다운로드 등에서 label 변환 로직 확인 |
| API 하위호환 | 기존 엔드포인트 유지, 새 필드는 optional |
| 시드 데이터 중복 | tenant별 기존 데이터 확인 후 없는 것만 추가 |

View File

@@ -1,6 +1,6 @@
# claudedocs 문서 맵
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-23)
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-03-09)
## 빠른 참조
@@ -10,6 +10,13 @@
| **[`[REF] technical-decisions.md`](./architecture/[REF]%20technical-decisions.md)** | 프로젝트 기술 결정 사항 (13개 항목) |
| **[`[GUIDE] common-page-patterns.md`](./guides/[GUIDE]%20common-page-patterns.md)** | 공통 페이지 패턴 가이드 |
## 주간 구현내역
| 기간 | 문서 |
|------|------|
| 2026-03-02 ~ 03-08 | **[`[IMPL-2026-03-08] frontend-weekly-0302-0308.md`](./%5BIMPL-2026-03-08%5D%20frontend-weekly-0302-0308.md)** |
| (백엔드 일별) | `backend/2026-03-02_구현내역.md` ~ `2026-03-08_구현내역.md` |
---
## 폴더 구조
@@ -38,9 +45,11 @@ claudedocs/
├── architecture/ # 아키텍처 & 시스템 & 기술 결정
├── changes/ # 변경이력
├── refactoring/ # 리팩토링 체크리스트
├── outbound/ # 출하/배차관리
├── vehicle/ # 차량관리
├── material/ # 자재관리
├── approval/ # 결재관리
├── backend/ # 백엔드 일별 구현내역
├── customer-center/ # 고객센터
├── components/ # 컴포넌트 문서
├── vercel/ # Vercel 배포

View File

@@ -0,0 +1,59 @@
# 전자결재 결재함 확장 및 연결문서 기능
> **작업일**: 2026-03-01 ~ 03-07
> **상태**: ✅ 완료
> **커밋**: 181352d7, 72cf5d86
---
## 개요
결재함(ApprovalBox) API 연동, 연결문서(LinkedDocumentContent) 렌더링,
모바일 반응형 레이아웃 개선.
---
## 1. 결재함 API 연동
- [x] 결재함 목록: `GET /api/v1/approvals/inbox`
- [x] 결재함 통계: `GET /api/v1/approvals/inbox/summary`
- [x] 승인 처리: `POST /api/v1/approvals/{id}/approve`
- [x] 반려 처리: `POST /api/v1/approvals/{id}/reject`
- [x] 문서 상태 매핑: DRAFT → PENDING → APPROVED / REJECTED / CANCELLED
- [x] 결재함 상태 헬퍼 함수 추가
### 주요 파일
- `src/components/approval/ApprovalBox/actions.ts` (+123/-7)
- `src/components/approval/ApprovalBox/index.tsx` (+47/-1)
- `src/components/approval/ApprovalBox/types.ts` (+9/-1)
---
## 2. 연결문서 기능 (LinkedDocumentContent) — 신규
검사성적서, 작업일지 등 문서관리 시스템의 문서를 결재 문서에 연결하여 렌더링.
- [x] `LinkedDocumentContent` 컴포넌트 신규 생성
- [x] `DocumentHeader` 컴포넌트 활용 (일관된 스타일)
- [x] 결재라인 / 상태배지 / 문서 메타정보 표시
- [x] `DocumentDetailModalV2`에 연결문서 렌더링 통합
### 주요 파일
- `src/components/approval/DocumentDetail/LinkedDocumentContent.tsx` (신규, +133)
- `src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx`
- `src/components/approval/DocumentDetail/types.ts` (+27/-1)
---
## 3. 모바일 반응형 개선
- [x] `AuthenticatedLayout`: 사이드바/메인 콘텐츠 모바일 대응
- [x] `HeaderFavoritesBar`: 전면 재설계 (+315/-127)
- [x] `Sidebar`: 반응형 숨김/표시
- [x] `SearchableSelectionModal`: HTML 유효성 에러 수정
### 주요 파일
- `src/layouts/AuthenticatedLayout.tsx` (+12/-1)
- `src/components/layout/HeaderFavoritesBar.tsx` (+315/-127)
- `src/components/layout/Sidebar.tsx` (+8/-1)
- `src/components/organisms/SearchableSelectionModal.tsx` (+79/-2)

View File

@@ -0,0 +1,281 @@
# 계정과목(Chart of Accounts) 현황 분석 및 일반 ERP 비교
> 작성일: 2026-03-06
> 목적: 회계담당자 피드백 기반, 현재 시스템 vs 일반 ERP 계정과목 체계 비교
---
## 1. 회계담당자 요구사항 요약
| # | 요구사항 | 핵심 |
|---|---------|------|
| 1 | 계정과목을 통일해서 관리 | 하나의 마스터에서 전사적 관리 |
| 2 | 번호와 명칭으로 구분 | 코드 체계 필수 (예: 401-매출, 501-급여) |
| 3 | 제조/회계 동일 명칭이지만 번호가 다른 경우 존재 | 부문별 세분화 필요 |
| 4 | 등록하면 전체가 공유 + 개별등록도 가능 | 공통 + 부문별 계정 |
---
## 2. 현재 시스템 계정과목 사용 현황
### 2.1 모듈별 계정과목 관리 방식
| 모듈 | 계정과목 소스 | 옵션 수 | 관리 방식 | API 필드명 |
|------|-------------|---------|----------|-----------|
| **일반전표입력** | DB 마스터 (account_codes) | 동적 | API CRUD | `account_subject_id` |
| **카드사용내역** | 프론트 하드코딩 | 16개 | 상수 배열 | `account_code` |
| **미지급비용** | 프론트 하드코딩 | 9개 | 상수 배열 | `account_code` |
| **매출관리** | 프론트 하드코딩 | 8개 | 상수 배열 | `account_code` |
| **입금관리** | 프론트 하드코딩 | ~11개 | depositType 상수 | `account_code` |
| **출금관리** | 프론트 하드코딩 | ~11개 | withdrawalType 상수 | `account_code` |
| **세금계산서관리** | 프론트 하드코딩 | 11개 | 상수 배열 (분개 모달) | `account_subject` |
| **CEO 대시보드** | 표시만 | - | account_title 표시 | `account_title` |
### 2.2 핵심 문제점
```
[문제 1] 계정과목 이원화
일반전표: DB 마스터 (code + name + category) ← 유일하게 정상
나머지: 프론트엔드 하드코딩 상수 배열 ← 각자 따로 관리
[문제 2] 코드 체계 불일치
일반전표: { code: "101", name: "현금", category: "asset" }
카드내역: { value: "purchasePayment", label: "매입대금" } ← 영문 키워드
입금관리: { value: "salesRevenue", label: "매출수금" } ← 또 다른 영문 키워드
[문제 3] 옵션 중복 + 불일치
"급여"가 카드내역(salary), 미지급비용(salary), 입출금(salary)에 각각 존재
세금계산서(분개)는 또 다른 옵션 세트 (매출, 부가세예수금 등)
하지만 서로 독립적이라 추가/수정 시 각 파일 개별 수정 필요
[문제 4] 번호 체계 없음
카드내역의 "매입대금" = 코드 없이 "purchasePayment"라는 문자열만 존재
제조에서 쓰는 "재료비"와 회계에서 쓰는 "재료비"를 구분할 방법 없음
```
### 2.3 백엔드 DB 구조 (현재)
```
account_codes 테이블 (일반전표 전용 마스터)
├── id (PK)
├── tenant_id (테넌트 격리)
├── code (varchar 10) ← 계정번호
├── name (varchar 100) ← 계정명
├── category (enum: asset/liability/capital/revenue/expense)
├── sort_order
├── is_active
├── created_at / updated_at
└── unique(tenant_id, code)
journal_entry_lines (분개 상세)
├── account_code (varchar) ← 코드 저장
├── account_name (varchar) ← 명칭 스냅샷 저장
└── ... (side, amount 등)
barobill_card_transactions (카드거래)
├── account_code (varchar) ← 문자열 직접 저장 ("purchasePayment" 등)
└── ...
barobill_card_transaction_splits (카드 분개)
├── account_code (varchar) ← 문자열 직접 저장
└── ...
```
---
## 3. 일반적인 ERP의 계정과목(Chart of Accounts) 체계
### 3.1 표준 구조
```
[계정과목표 = Chart of Accounts]
계정분류(대분류)
├── 1xxx: 자산 (Assets)
│ ├── 11xx: 유동자산
│ │ ├── 1101: 현금
│ │ ├── 1102: 보통예금
│ │ ├── 1103: 당좌예금
│ │ ├── 1110: 매출채권
│ │ └── 1120: 선급금
│ └── 12xx: 비유동자산
│ ├── 1201: 토지
│ ├── 1202: 건물
│ └── 1210: 기계장치
├── 2xxx: 부채 (Liabilities)
│ ├── 21xx: 유동부채
│ │ ├── 2101: 매입채무
│ │ ├── 2102: 미지급금
│ │ └── 2110: 예수금
│ └── 22xx: 비유동부채
├── 3xxx: 자본 (Equity)
│ ├── 3101: 자본금
│ └── 3201: 이익잉여금
├── 4xxx: 수익 (Revenue)
│ ├── 4101: 제품매출
│ ├── 4102: 상품매출
│ └── 4201: 임대수익
└── 5xxx: 비용 (Expenses)
├── 51xx: 매출원가
│ ├── 5101: 재료비 (제조) ← 코드로 구분!
│ └── 5102: 노무비
├── 52xx: 판매비와관리비
│ ├── 5201: 급여
│ ├── 5202: 복리후생비
│ ├── 5203: 접대비
│ ├── 5210: 재료비 (관리) ← 같은 명칭, 다른 코드!
│ └── 5220: 임차료
└── 53xx: 영업외비용
├── 5301: 이자비용
└── 5302: 외환차손
```
### 3.2 일반 ERP 계정과목 마스터 구조
```
account_subjects (계정과목 마스터)
├── id (PK)
├── code (varchar 10) ← "5101" 같은 번호 (4~6자리)
├── name (varchar 100) ← "재료비"
├── category (대분류) ← 자산/부채/자본/수익/비용
├── sub_category (중분류) ← 유동자산/비유동자산/매출원가/판관비 등
├── parent_code (상위 계정) ← 계층 구조용
├── depth (계층 깊이) ← 1=대, 2=중, 3=소
├── department_type (부문) ← 제조/관리/공통 등
├── is_control (통제계정) ← 하위 세부계정 존재 여부
├── is_active (사용여부)
├── sort_order
├── description (설명)
└── tenant_id
```
### 3.3 일반 ERP vs 현재 SAM ERP 비교
| 항목 | 일반 ERP | SAM ERP (현재) | 차이 |
|------|---------|---------------|------|
| **마스터 테이블** | 1개 (전사 공유) | 1개 있지만 일반전표만 사용 | 다른 모듈 미연동 |
| **코드 체계** | 4~6자리 숫자 (1101, 5201) | 일반전표만 code 있음, 나머지 영문 키워드 | 번호 체계 불통일 |
| **계층 구조** | 대-중-소 분류 (parent_code) | 대분류(5개)만 존재 | 중/소분류 없음 |
| **부문 구분** | department_type으로 제조/관리 분리 | 없음 | 제조vs회계 구분 불가 |
| **공유 범위** | 전 모듈이 같은 마스터 참조 | 각 모듈 독자 관리 | 핵심 문제 |
| **등록 방식** | 계정과목 설정 화면 1곳 | 일반전표 설정에서만 등록 | 접근성 제한 |
| **사용처 추적** | 어떤 전표에서 사용되는지 추적 | 없음 | 감사 추적 불가 |
| **잠금/보호** | 사용 중인 계정 삭제 방지 | 없음 | 데이터 무결성 위험 |
---
## 4. 담당자 요구사항 vs 현재 시스템 GAP 분석
### 요구 1: "계정과목을 통일해서 관리"
```
현재 상태:
일반전표 → account_codes 테이블 (DB)
세금계산서 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 11개) - 분개 모달
카드내역 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 16개)
미지급비용 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 9개)
매출관리 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 8개)
입금관리 → depositType 상수
출금관리 → withdrawalType 상수
필요한 것:
모든 모듈 → account_codes 테이블 (DB) 하나만 참조
GAP: 크다 (프론트 하드코딩 → DB 마스터 참조로 전환 필요)
```
### 요구 2: "번호와 명칭으로 구분"
```
현재 상태:
일반전표: code="101", name="현금" ← 있음
카드내역: value="salary", label="급여" ← 영문 키워드, 번호 없음
필요한 것:
모든 곳에서: code="5201", name="급여" 형태로 표시
UI에서: "5201 - 급여" 또는 "[5201] 급여" 식으로 코드+명칭 동시 표시
GAP: 중간 (코드 체계는 DB에 이미 있으나, 다른 모듈이 참조하지 않음)
```
### 요구 3: "제조/회계 동일 명칭, 번호로 구분"
```
현재 상태:
구분 불가. "재료비"가 제조인지 관리인지 알 방법 없음
필요한 것:
5101: 재료비 (제조 - 매출원가)
5210: 재료비 (판관비 - 관리비용)
→ 코드가 다르므로 자동 구분
GAP: 크다 (중분류 + 부문 구분 필드 추가 필요)
```
### 요구 4: "전체 공유 + 개별 등록 가능"
```
현재 상태:
일반전표 설정에서만 등록 가능. 다른 모듈은 하드코딩이라 등록 개념 없음.
필요한 것:
- 기본 계정과목표 (회사 설정 시 일괄 생성)
- 추가 등록 (필요에 따라 개별 계정과목 추가)
- 전 모듈 공유 (등록 즉시 카드, 입출금, 세금계산서 등에서 사용 가능)
GAP: 중간 (DB 마스터는 있으니, 다른 모듈이 참조하도록 연결만 하면 됨)
```
---
## 5. 결론 및 권장사항
### 5.1 담당자 말씀이 맞는가?
**맞습니다.** 일반적인 ERP에서 계정과목은 반드시:
- 하나의 마스터(Chart of Accounts)로 전사 통합 관리
- 숫자 코드 + 명칭으로 식별 (코드가 PK 역할)
- 코드 번호로 계정 분류/부문 구분 (제조 5101 vs 관리 5210)
- 한 번 등록하면 모든 회계 모듈에서 공유
현재 SAM ERP는 일반전표에만 정상적인 마스터가 있고, 나머지는 각자 하드코딩이므로
**회계적으로 올바르지 않은 상태**입니다.
### 5.2 개선 방향 (단계별)
```
[Phase 1] 계정과목 마스터 강화 (백엔드)
- account_codes 테이블에 sub_category, parent_code, depth, department_type 추가
- 표준 계정과목표 시드 데이터 준비 (대/중/소 분류)
- 코드 체계 확정 (4자리 vs 6자리)
[Phase 2] 계정과목 설정 화면 독립 (프론트)
- 일반전표 내부 모달 → 독립 메뉴로 분리 (회계 > 계정과목 설정)
- 계층 구조 표시 (트리뷰 또는 들여쓰기 목록)
- 대량 등록 (Excel import), 기본 계정과목표 초기 세팅
[Phase 3] 전 모듈 통합 (프론트 + 백엔드)
- 세금계산서관리: ACCOUNT_SUBJECT_OPTIONS 상수 (11개) → DB 마스터 API 호출로 전환
- 카드사용내역: ACCOUNT_SUBJECT_OPTIONS 상수 (16개) → DB 마스터 API 호출로 전환
- 입금/출금관리: depositType/withdrawalType → DB 마스터 참조로 전환
- 미지급비용, 매출관리: 동일하게 전환
- Select UI에 "코드 - 명칭" 형태로 표시 (예: "[5201] 급여")
[Phase 4] 고급 기능
- 사용중 계정 삭제 방지 (참조 무결성)
- 계정과목별 거래 내역 조회
- 기간별 잔액 집계
```
### 5.3 작업 규모 예상
| Phase | 범위 | 핵심 변경 |
|-------|------|----------|
| 1 | 백엔드 마이그레이션 + 시드 | account_codes 테이블 확장, 시드 데이터 |
| 2 | 프론트 1개 페이지 신규 | 계정과목 설정 독립 페이지 |
| 3 | 프론트 6~7개 모듈 수정, 백엔드 API 조정 | 하드코딩 → API 참조 전환 |
| 4 | 양쪽 추가 개발 | 무결성, 집계, 조회 |

View File

@@ -0,0 +1,103 @@
# 문서스냅샷 시스템 (Lazy Snapshot)
> **작업일**: 2026-03-06 ~ 03-07
> **상태**: ✅ 완료
> **커밋**: 31f523c8, a1fb0d4f, 8250eaf2, 72a2a3e9, 04f2a8a7
---
## 개요
문서 저장/조회 시 `rendered_html` 스냅샷을 자동 캡처하여 백엔드에 전송하는 시스템.
MNG 측에서 문서 인쇄 시 스냅샷 기반 렌더링에 활용.
---
## 아키텍처
```
[문서 저장 시]
컴포넌트 → contentWrapperRef.innerHTML 캡처
→ API 요청에 rendered_html 파라미터 포함 → 백엔드 저장
[문서 조회 시 — Lazy Snapshot]
rendered_html === NULL 감지
→ 500ms 대기 (렌더링 완료 대기)
→ innerHTML 캡처
→ 백그라운드 PATCH 전송 (비차단)
```
---
## 1. 수동 캡처 (저장 시)
문서 저장 시 DOM에서 `innerHTML`을 읽어 `rendered_html` 파라미터로 함께 전송.
- [x] 검사성적서 (InspectionReportModal) — `contentWrapperRef.innerHTML`
- [x] 작업일지 (WorkLogModal) — `contentWrapperRef.innerHTML`
- [x] 수입검사 (ImportInspectionInputModal) — 오프스크린 렌더링 방식
### 주요 파일
- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx`
- `src/components/production/WorkerScreen/WorkLogModal.tsx`
- `src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx`
---
## 2. Lazy Snapshot (조회 시 자동 캡처)
`rendered_html`이 NULL인 기존 문서를 조회할 때 자동으로 스냅샷을 캡처하여 백그라운드 저장.
### 동작 흐름
1. 문서 조회 API 응답에서 `snapshot_document_id` 확인
2. `rendered_html === NULL` → Lazy Snapshot 트리거
3. 500ms 지연 (콘텐츠 렌더링 완료 대기)
4. `contentWrapperRef.innerHTML` 캡처
5. `patchDocumentSnapshot()` 서버 액션으로 백그라운드 PATCH
### 특성
- **비차단(non-blocking)**: UI에 영향 없이 백그라운드 처리
- **1회성**: 스냅샷 저장 후 재조회 시 캡처하지 않음
- **readOnly 자동 캡처 제거**: 불필요한 PUT 요청 방지
### 적용 대상
| 문서 | 수동 캡처 | Lazy Snapshot |
|------|-----------|---------------|
| 검사성적서 | ✅ | ✅ |
| 작업일지 | ✅ | ✅ |
| 수입검사 | ✅ (오프스크린) | — |
| 제품검사 요청서 | ✅ | ✅ |
---
## 3. 오프스크린 렌더링 유틸리티
폼 HTML이 아닌 실제 문서 렌더링 결과를 캡처하기 위한 유틸리티.
```typescript
// src/lib/utils/capture-rendered-html.tsx
// 오프스크린 DOM에 문서 컴포넌트를 렌더링하여 innerHTML 추출
```
- [x] 수입검사 모달에서 활용 (폼 캡처 → 문서 캡처 전환)
- [x] DocumentViewer 스냅샷 렌더링 지원
### 주요 파일
- `src/lib/utils/capture-rendered-html.tsx` (신규)
- `src/components/document-system/viewer/DocumentViewer.tsx`
---
## 4. 서버 액션
```typescript
// patchDocumentSnapshot — 백그라운드 PATCH
export async function patchDocumentSnapshot(
documentId: string,
rendered_html: string
): Promise<{ success: boolean }>;
```
### 주요 파일
- `src/components/production/WorkOrders/actions.ts``patchDocumentSnapshot`
- `src/components/quality/InspectionManagement/fqcActions.ts``patchDocumentSnapshot`

View File

@@ -0,0 +1,38 @@
# 2026-03-02 (월) 백엔드 구현 내역
## 1. `🆕 신규` [roadmap] 중장기 계획 테이블 마이그레이션 추가
**커밋**: `3ca161e` | **유형**: feat
### 배경
관리자 패널에서 프로젝트 로드맵을 관리할 수 있도록 데이터베이스 테이블이 필요했음.
### 구현 내용
- `admin_roadmap_plans` 테이블 생성 — 계획 마스터 (제목, 카테고리, 상태, Phase, 진행률)
- `admin_roadmap_milestones` 테이블 생성 — 마일스톤 관리 (plan_id FK, 상태, 예정일)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_02_000000_create_admin_roadmap_tables.php` | 신규 생성 |
---
## 2. `🆕 신규` [rd] AI 견적 엔진 테이블 생성 + 모듈 카탈로그 시더
**커밋**: `abe0460` | **유형**: feat
### 배경
AI 기반 자동 견적 시스템을 위한 데이터 저장 구조 및 초기 모듈 카탈로그 데이터가 필요했음.
### 구현 내용
- `ai_quotation_modules` 테이블 — SAM 모듈 카탈로그 (18개 모듈 정의)
- `ai_quotations` 테이블 — AI 견적 요청/결과 저장
- `ai_quotation_items` 테이블 — AI 추천 모듈 목록
- `AiQuotationModuleSeeder` — customer-pricing 기반 초기 데이터 시딩
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_02_100000_create_ai_quotation_tables.php` | 신규 생성 |
| `database/seeders/AiQuotationModuleSeeder.php` | 신규 생성 |

View File

@@ -0,0 +1,197 @@
# 2026-03-03 (화) 백엔드 구현 내역
## 1. `⚙️ 설정` [ai] Gemini 모델 버전 업그레이드
**커밋**: `f79d008` | **유형**: chore
### 배경
Google Gemini 모델의 새 버전(2.5-flash)이 출시되어 기존 2.0-flash에서 업그레이드 필요.
### 구현 내용
- `config/services.php` — fallback 기본 모델명 `gemini-2.5-flash`로 변경
- `AiReportService.php` — fallback 기본값 동일 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `config/services.php` | 수정 |
| `app/Services/AiReportService.php` | 수정 |
---
## 2. `🔧 수정` [deploy] 배포 시 .env 권한 640 보장 추가
**커밋**: `7e309e4` | **유형**: fix
### 배경
2026-03-03 장애 발생 — vi 편집으로 `.env` 파일 권한이 600으로 변경되어 PHP-FPM이 읽기 실패 → 500 에러. 재발 방지를 위해 배포 파이프라인에 권한 보장 로직 추가.
### 구현 내용
- Stage/Production Jenkinsfile 배포 스크립트에 `chmod 640 .env` 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `Jenkinsfile` | 수정 |
---
## 3. `🔧 수정` [hr] 사업소득자 임금대장 컬럼 추가
**커밋**: `b3c7d08` | **유형**: feat (기존 테이블 확장)
### 배경
사업소득자(프리랜서)를 시스템 회원이 아닌 직접 입력 대상자로 지원하기 위해 추가 컬럼 필요.
### 구현 내용
- `user_id` nullable 변경 (직접 입력 대상자 지원)
- `display_name`, `business_reg_number` 컬럼 추가
- 기존 데이터는 earner 프로필에서 자동 채움 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_display_name_to_business_income_payments.php` | 신규 생성 |
---
## 4. `🔧 수정` [ai-quotation] 제조 견적서 마이그레이션 추가
**커밋**: `da1142a` | **유형**: feat (기존 테이블 확장)
### 배경
AI 견적 시스템에서 제조업 견적서를 지원하기 위해 기존 테이블 확장 및 가격표 테이블 신규 생성 필요.
### 구현 내용
- `ai_quotations` 테이블에 `quote_mode`, `quote_number`, `product_category` 컬럼 추가
- `ai_quotation_items` 테이블에 `specification`, `unit`, `quantity`, `unit_price`, `total_price`, `item_category`, `floor_code` 컬럼 추가
- `ai_quote_price_tables` 테이블 신규 생성
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_manufacture_fields_to_ai_quotations.php` | 신규 생성 |
---
## 5. `🔧 수정` [today-issue] 날짜 기반 이전 이슈 조회 기능 추가
**커밋**: `83a7745` | **유형**: feat (기존 기능 확장)
### 배경
오늘의 이슈를 특정 날짜 기준으로 과거 데이터도 조회할 수 있어야 함. 이전에는 현재 날짜 기준만 지원했음.
### 구현 내용
- `TodayIssueController``date` 파라미터(YYYY-MM-DD) 추가
- `TodayIssueService.summary()`에 날짜 기반 필터링 로직 구현
- 이전 이슈 조회 시 만료(active) 필터 무시하여 과거 데이터 조회 가능
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/TodayIssueController.php` | 수정 |
| `app/Services/TodayIssueService.php` | 수정 |
---
## 6. `🔧 수정` [approval] 결재 수신함 날짜 범위 필터 추가
**커밋**: `b7465be` | **유형**: feat (기존 기능 확장)
### 배경
결재 수신함에서 특정 기간의 결재 건만 조회할 수 있도록 날짜 필터 필요.
### 구현 내용
- `InboxIndexRequest``start_date`/`end_date` 검증 룰 추가
- `ApprovalService.inbox()``created_at` 날짜 범위 필터 구현
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/Approval/InboxIndexRequest.php` | 수정 |
| `app/Services/ApprovalService.php` | 수정 |
---
## 7. `🔧 수정` [daily-report] 자금현황 카드용 필드 추가
**커밋**: `ad27090` | **유형**: feat (기존 API 확장)
### 배경
일일보고서 대시보드에 자금현황 카드를 표시하기 위해 미수금/미지급금/당월 예상 지출 데이터 필요.
### 구현 내용
- 미수금 잔액(`receivable_balance`) 계산 로직 구현
- 미지급금 잔액(`payable_balance`) 계산 로직 구현
- 당월 예상 지출(`monthly_expense_total`) 계산 로직 구현
- summary API 응답에 자금현황 3개 필드 포함
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/DailyReportService.php` | 수정 |
---
## 8. `🔧 수정` [stock,client,status-board] 날짜 필터 및 조건 보완
**커밋**: `4244334` | **유형**: feat (기존 기능 확장)
### 배경
재고/거래처/현황판 화면에서 날짜 범위 필터가 미지원이었고, 부실채권 현황에 비활성 데이터가 포함되는 이슈.
### 구현 내용
- `StockController/StockService` — 입출고 이력 기반 날짜 범위 필터 추가
- `ClientService` — 등록일 기간 필터(`start_date`/`end_date`) 추가
- `StatusBoardService` — 부실채권 현황에 `is_active` 조건 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/StockController.php` | 수정 |
| `app/Services/StockService.php` | 수정 |
| `app/Services/ClientService.php` | 수정 |
| `app/Services/StatusBoardService.php` | 수정 |
---
## 9. `🔧 수정` [hr] Leave 모델 확장 + 결재양식 마이그레이션 추가
**커밋**: `23c6cf6` | **유형**: feat (기존 모델 확장)
### 배경
기존 연차/반차만 지원하던 휴가 시스템에 출장, 재택근무, 외근, 조퇴, 지각, 결근 등 근태 유형 확장 필요. 결재 양식(근태신청, 사유서)도 추가.
### 구현 내용
- Leave 타입 6개 추가: `business_trip`, `remote`, `field_work`, `early_leave`, `late_reason`, `absent_reason`
- 그룹 상수: `VACATION_TYPES`, `ATTENDANCE_REQUEST_TYPES`, `REASON_REPORT_TYPES`
- `FORM_CODE_MAP` — 유형 → 결재양식코드 매핑
- `ATTENDANCE_STATUS_MAP` — 유형 → 근태상태 매핑
- 결재양식 2개 추가: `attendance_request`(근태신청), `reason_report`(사유서)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/Leave.php` | 수정 |
| `database/migrations/..._insert_attendance_approval_forms.php` | 신규 생성 |
---
## 10. `🔧 수정` [production] 자재투입 모달 개선
**커밋**: `fc53789` | **유형**: fix (기존 기능 버그 수정 + 개선)
### 배경
자재투입 시 lot 미관리 품목(L-Bar, 보강평철)이 목록에 표시되는 이슈, BOM 그룹키 부재로 동일 자재 구분 불가, 셔터박스 순서가 작업일지와 불일치.
### 구현 내용
- `getMaterialsForItem``lot_managed===false` 품목을 자재투입 목록에서 제외
- `getMaterialsForItem``bom_group_key` 필드 추가 (category+partType 기반 고유키)
- `BendingInfoBuilder``shutterPartTypes`에서 `top_cover`/`fin_cover` 제거 (중복 방지)
- `BendingInfoBuilder` — 셔터박스 루프 순서 파트→길이로 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/Production/BendingInfoBuilder.php` | 수정 |
| `app/Services/WorkOrderService.php` | 수정 |

View File

@@ -0,0 +1,336 @@
# 2026-03-04 (수) 백엔드 구현 내역
## 1. `🔧 수정` [inspection] 캘린더 스케줄 조회 API 추가
**커밋**: `e9fd75f` | **유형**: feat (기존 검사 모듈에 캘린더 API 추가)
### 배경
검사 일정을 캘린더 형태로 표시하기 위한 API 필요.
### 구현 내용
- `GET /api/v1/inspections/calendar` 엔드포인트 추가
- `year`, `month`, `inspector`, `status` 파라미터 지원
- React 프론트엔드 `CalendarItemApi` 형식에 맞춰 응답
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/InspectionController.php` | 수정 |
| `app/Services/InspectionService.php` | 수정 |
| `routes/api/v1/production.php` | 수정 |
---
## 2. `🆕 신규` [barobill] 바로빌 연동 API 엔드포인트 추가
**커밋**: `4f3467c` | **유형**: feat
### 배경
바로빌(전자세금계산서/은행/카드 연동 서비스) API 연동을 위한 백엔드 엔드포인트 필요.
### 구현 내용
- `GET /api/v1/barobill/status` — 연동 현황 조회
- `POST /api/v1/barobill/login` — 로그인 정보 등록
- `POST /api/v1/barobill/signup` — 회원가입 정보 등록
- `GET /api/v1/barobill/bank-service-url` — 은행 서비스 URL
- `GET /api/v1/barobill/account-link-url` — 계좌 연동 URL
- `GET /api/v1/barobill/card-link-url` — 카드 연동 URL
- `GET /api/v1/barobill/certificate-url` — 공인인증서 URL
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/BarobillController.php` | 신규 생성 |
| `routes/api/v1/finance.php` | 수정 |
---
## 3. `🔧 수정` [expense,loan] 대시보드 상세 필터 및 가지급금 카테고리 분류
**커밋**: `1deeafc` | **유형**: feat (기존 대시보드 확장)
### 배경
경비/가지급금 대시보드에서 날짜 범위 필터와 검색 기능이 없었고, 가지급금에 카테고리(카드/경조사/상품권/접대비) 분류 필요.
### 구현 내용
- `ExpectedExpenseController/Service` — dashboardDetail에 `start_date`/`end_date`/`search` 파라미터 추가
- `Loan` 모델 — category 상수 및 라벨 정의 (카드/경조사/상품권/접대비)
- `LoanService` — dashboard에 `category_breakdown` 집계 추가
- 마이그레이션 — loans 테이블 `category` 컬럼 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/ExpectedExpenseController.php` | 수정 |
| `app/Models/Tenants/Loan.php` | 수정 |
| `app/Services/ExpectedExpenseService.php` | 수정 |
| `app/Services/LoanService.php` | 수정 |
| `database/migrations/2026_03_04_100000_add_category_to_loans_table.php` | 신규 생성 |
---
## 4. `🔧 수정` [models] User 모델 import 누락/오류 수정
**커밋**: `da04b84` | **유형**: fix (버그 수정)
### 배경
Tenants 네임스페이스에서 `User::class``App\Models\Tenants\User`로 잘못 해석되는 문제. Loan, TodayIssue 모델에서 User import 경로 오류.
### 구현 내용
- `Loan.php``App\Models\Members\User` import 추가
- `TodayIssue.php``App\Models\Users\User``App\Models\Members\User` 수정
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/Loan.php` | 수정 |
| `app/Models/Tenants/TodayIssue.php` | 수정 |
---
## 5. `🔧 수정` [cards] 리다이렉트 추가
**커밋**: `76192fc` | **유형**: fix (하위호환)
### 배경
프론트엔드에서 기존 `cards/stats` 경로로 호출하는 코드가 있어 새 경로로 리다이렉트 필요.
### 구현 내용
- `cards/stats``card-transactions/dashboard` 리다이렉트 라우트 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `routes/api/v1/finance.php` | 수정 |
---
## 6. `🔧 수정` [address] 주소 필드 255자 → 500자 확장
**커밋**: `7cf70db` | **유형**: fix (제한 완화)
### 배경
실제 주소 데이터가 255자를 초과하는 경우 발생. DB와 FormRequest 검증 모두 확장 필요.
### 구현 내용
- DB 마이그레이션 — `clients`, `tenants`, `site_briefings`, `sites` 테이블 address 컬럼 `varchar(500)`
- FormRequest 8개 파일 — `max:255``max:500` 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/Client/ClientStoreRequest.php` | 수정 |
| `app/Http/Requests/Client/ClientUpdateRequest.php` | 수정 |
| `app/Http/Requests/SiteBriefing/StoreSiteBriefingRequest.php` | 수정 |
| `app/Http/Requests/SiteBriefing/UpdateSiteBriefingRequest.php` | 수정 |
| `app/Http/Requests/Tenant/TenantStoreRequest.php` | 수정 |
| `app/Http/Requests/Tenant/TenantUpdateRequest.php` | 수정 |
| `app/Http/Requests/V1/Site/StoreSiteRequest.php` | 수정 |
| `app/Http/Requests/V1/Site/UpdateSiteRequest.php` | 수정 |
| `database/migrations/..._extend_address_columns_to_500.php` | 신규 생성 |
---
## 7. `🔧 수정` [dashboard] D1.7 기획서 기반 리스크 감지형 서비스 리팩토링
**커밋**: `e637e3d` | **유형**: feat (기존 대시보드 대규모 리팩토링)
### 배경
D1.7 기획서 요구사항에 따라 접대비/복리후생비/매출채권 대시보드를 단순 집계에서 리스크 감지형으로 전환.
### 구현 내용
- `EntertainmentService` — 리스크 감지형 전환 (주말/심야, 기피업종, 고액결제, 증빙미비)
- `WelfareService` — 리스크 감지형 전환 (비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과)
- `ReceivablesService` — summary를 `cards` + `check_points` 구조로 개선 (누적/당월 미수금, Top3 거래처)
- `LoanService` — getCategoryBreakdown 전체 대상으로 집계 조건 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/EntertainmentService.php` | 수정 (대규모) |
| `app/Services/WelfareService.php` | 수정 (대규모) |
| `app/Services/ReceivablesService.php` | 수정 (대규모) |
| `app/Services/LoanService.php` | 수정 |
---
## 8. `🔧 수정` [entertainment,welfare] 바로빌 조인 컬럼명 및 심야 시간 파싱 수정
**커밋**: `f665d3a` | **유형**: fix (버그 수정)
### 배경
바로빌 카드거래 테이블 조인 시 컬럼명 불일치 및 심야 판별 함수 오류.
### 구현 내용
- `approval_no``approval_num` 컬럼명 수정
- `use_time` 심야 판별: `HOUR()``SUBSTRING` 문자열 파싱으로 변경
- `whereNotNull('bct.use_time')` 조건 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/EntertainmentService.php` | 수정 |
| `app/Services/WelfareService.php` | 수정 |
---
## 9. `🆕 신규` [approval] 지출결의서 양식 등록 및 고도화
**커밋**: `b86af29`, `282bf26` | **유형**: feat
### 배경
전자결재에 지출결의서 양식을 등록하고, HTML body_template 필드로 정형화된 양식 제공.
### 구현 내용
- `approval_forms` 테이블에 `body_template` TEXT 컬럼 추가 (마이그레이션)
- 지출결의서(expense) 양식 데이터 등록
- 참조 문서 기반으로 정형 양식 HTML 리디자인 — 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_body_template_to_approval_forms.php` | 신규 생성 |
| `database/migrations/..._insert_expense_approval_form.php` | 신규 생성 |
| `database/migrations/..._update_expense_approval_form_body_template.php` | 신규 생성 |
---
## 10. `🆕 신규` [entertainment] 접대비 상세 조회 API + `🔧 수정` 가지급금 날짜 필터
**커밋**: `66da297`, `a173a5a`, `94b96e2`, `2f3ec13` | **유형**: feat + fix
### 배경
접대비 상세 대시보드(손금한도, 월별추이, 거래내역)가 필요하고, 가지급금 대시보드에도 날짜 필터 지원 필요.
### 구현 내용
- `EntertainmentController/Service``getDetail()` 상세 조회 API 신규 (손금한도, 월별추이, 사용자분포, 거래내역, 분기현황)
- 수입금액별 추가한도 계산 (세법 기준), 거래건별 리스크 감지
- `LoanController/Service` — dashboard에 `start_date`/`end_date` 파라미터 지원 (기존 수정)
- `getCategoryBreakdown` SQL alias 충돌 수정
- 분기 사용액 조회에 날짜 필터 적용
- 라우트: `GET /entertainment/detail` 엔드포인트 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/EntertainmentController.php` | 수정 |
| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 |
| `app/Services/EntertainmentService.php` | 수정 (대규모) |
| `app/Services/LoanService.php` | 수정 |
| `routes/api/v1/finance.php` | 수정 |
---
## 11. `🆕 신규` [calendar,vat] 캘린더 CRUD 및 부가세 상세 조회 API
**커밋**: `74a60e0` | **유형**: feat
### 배경
일정 관리를 위한 캘린더 CRUD API와 부가세 상세 조회 대시보드 API 필요.
### 구현 내용
- `CalendarController/Service` — 일정 등록/수정/삭제 API 신규
- `VatController/Service``getDetail()` 상세 조회 신규 (요약, 참조테이블, 미발행 목록, 신고기간 옵션)
- 라우트: `POST/PUT/DELETE /calendar/schedules`, `GET /vat/detail`
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/CalendarController.php` | 신규 생성 |
| `app/Http/Controllers/Api/V1/VatController.php` | 신규 생성 |
| `app/Services/CalendarService.php` | 신규 생성 |
| `app/Services/VatService.php` | 신규 생성 |
| `routes/api/v1/finance.php` | 수정 |
---
## 12. `🆕 신규` [shipment] 배차정보 다중 행 시스템
**커밋**: `851862` | **유형**: feat
### 배경
기존 출하 건에 단일 배차정보만 저장 가능했으나, 다중 차량 배차를 지원해야 함.
### 구현 내용
- `shipment_vehicle_dispatches` 테이블 신규 생성 (seq, logistics_company, arrival_datetime, tonnage, vehicle_no, driver_contact, remarks)
- `ShipmentVehicleDispatch` 모델 신규
- `Shipment` 모델에 `vehicleDispatches()` HasMany 관계 추가
- `ShipmentService``syncDispatches()` 추가, store/update/delete/show/index에서 연동
- FormRequest — Store/Update에 `vehicle_dispatches` 배열 검증 규칙 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 신규 생성 |
| `app/Models/Tenants/Shipment.php` | 수정 |
| `app/Services/ShipmentService.php` | 수정 |
| `app/Http/Requests/Shipment/ShipmentStoreRequest.php` | 수정 |
| `app/Http/Requests/Shipment/ShipmentUpdateRequest.php` | 수정 |
| `database/migrations/..._create_shipment_vehicle_dispatches_table.php` | 신규 생성 |
---
## 13. `🔧 수정` [production] 자재투입 bom_group_key 개별 저장
**커밋**: `5ee97c2` | **유형**: fix (기존 기능 보완)
### 배경
동일 자재가 다른 BOM 그룹에 속할 때 구분이 안 되는 문제. bom_group_key로 개별 식별 필요.
### 구현 내용
- `work_order_material_inputs` 테이블에 `bom_group_key` 컬럼 추가
- 기투입 조회를 `stock_lot_id` + `bom_group_key` 복합키로 변경
- `replace` 모드 지원 (기존 삭제 → 재등록)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php` | 수정 |
| `app/Models/Production/WorkOrderMaterialInput.php` | 수정 |
| `app/Services/WorkOrderService.php` | 수정 |
| `database/migrations/..._bom_group_key_to_work_order_material_inputs.php` | 신규 생성 |
---
## 14. `🔧 수정` [production] 절곡 검사 데이터 전체 item 복제 + bending EAV 변환
**커밋**: `897511c` | **유형**: fix (기존 검사 로직 개선)
### 배경
절곡 검사 시 동일 작업지시의 모든 item에 검사 데이터가 복제 저장되어야 하며, products 배열을 bending EAV 레코드로 변환 필요.
### 구현 내용
- `storeItemInspection` — bending/bending_wip 시 동일 작업지시 모든 item에 복제 저장
- `transformBendingProductsToRecords` — products 배열 → bending EAV 레코드 변환
- `getMaterialInputLots` — 품목코드별 그룹핑으로 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/WorkOrderService.php` | 수정 (대규모) |
---
## 15. `🆕 신규` [outbound] 배차차량 관리 API
**커밋**: `1a8bb46` | **유형**: feat
### 배경
출고 관련 배차차량을 독립적으로 관리(조회/수정/통계)하는 API 필요.
### 구현 내용
- `VehicleDispatchService` — index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update
- `VehicleDispatchController` + `VehicleDispatchUpdateRequest`
- options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer)
- inventory.php에 `vehicle-dispatches` 라우트 4개 등록
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/VehicleDispatchController.php` | 신규 생성 |
| `app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php` | 신규 생성 |
| `app/Services/VehicleDispatchService.php` | 신규 생성 |
| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 수정 |
| `app/Services/ShipmentService.php` | 수정 |
| `database/migrations/..._options_to_shipment_vehicle_dispatches_table.php` | 신규 생성 |
| `routes/api/v1/inventory.php` | 수정 |

View File

@@ -0,0 +1,386 @@
# 2026-03-05 (목) 백엔드 구현 내역
## 1. `🔧 수정` [storage] RecordStorageUsage 명령어 수정
**커밋**: `e0bb19a` | **유형**: fix (버그 수정)
### 배경
`Tenant::where('status', 'active')` 하드코딩 사용 중이나 tenants 테이블에 `status` 컬럼이 없고 `tenant_st_code`를 사용함. 모델 스코프 사용으로 수정.
### 구현 내용
- `Tenant::where('status', 'active')``Tenant::active()` 스코프 사용
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Console/Commands/RecordStorageUsage.php` | 수정 |
---
## 2. `🆕 신규` [dashboard-ceo] CEO 대시보드 섹션별 API 및 일일보고서 엑셀
**커밋**: `e8da2ea`, `f1a3e0f` | **유형**: feat + fix
### 배경
CEO 전용 대시보드에 매출/매입/생산/미출고/시공/근태 등 6개 섹션 데이터를 제공하는 API 및 엑셀 다운로드 기능 필요.
### 구현 내용
- `DashboardCeoController/Service` — 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태)
- `DailyReportController/Service` — 엑셀 다운로드 API (`GET /daily-report/export`)
- 라우트: dashboard 하위 6개 + `daily-report/export` 엔드포인트
- 공정명 컬럼 수정 (`p.name``p.process_name`)
- 근태 부서 조인 수정 (`users.department_id``tenant_user_profiles` 경유)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/DashboardCeoController.php` | 신규 생성 |
| `app/Services/DashboardCeoService.php` | 신규 생성 |
| `app/Http/Controllers/Api/V1/DailyReportController.php` | 수정 |
| `app/Services/DailyReportService.php` | 수정 |
| `routes/api/v1/common.php` | 수정 |
| `routes/api/v1/finance.php` | 수정 |
---
## 3. `🔧 수정` [daily-report] 엑셀 내보내기 어음/외상매출채권 현황 및 리팩토링
**커밋**: `1b2363d`, `fefd129` | **유형**: feat + refactor (기존 엑셀 기능 확장/개선)
### 배경
일일보고서 엑셀에 어음/외상매출채권 현황 섹션이 빠져있었고, 엑셀과 화면 데이터가 불일치하는 문제.
### 구현 내용
- `DailyReportExport` — 어음 현황 테이블 + 합계 + 스타일링 추가
- `DailyReportService` — exportData를 `dailyAccounts()` 재사용 구조로 리팩토링
- 헤더 라벨 전월이월/당월입금/당월출금/잔액으로 수정
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Exports/DailyReportExport.php` | 수정 |
| `app/Services/DailyReportService.php` | 수정 (리팩토링) |
---
## 4. `🔧 수정` [production] 절곡 검사 FormRequest 검증 누락 수정
**커밋**: `ef7d9fa` | **유형**: fix (버그 수정)
### 배경
`StoreItemInspectionRequest``inspection_data.products` 검증 규칙이 누락되어 `validated()`에서 products 데이터가 제거되는 버그.
### 구현 내용
- `products.*.id`, `bendingStatus`, `lengthMeasured`, `widthMeasured`, `gapPoints` 검증 규칙 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php` | 수정 |
---
## 5. `🆕 신규` [approval] Document ↔ Approval 브릿지 연동 (Phase 4.2)
**커밋**: `cd847e0` | **유형**: feat
### 배경
문서(Document) 시스템과 결재(Approval) 시스템을 연동하여, 문서 상신 시 결재가 자동 생성되고 결재 처리 시 문서 상태가 동기화되어야 함.
### 구현 내용
- `Approval` 모델에 `linkable` morphTo 관계 추가
- `DocumentService` — 상신 시 Approval 자동 생성 + approval_steps 변환
- `ApprovalService` — 승인/반려/회수 시 Document 상태 동기화
- `approvals` 테이블에 `linkable_type`, `linkable_id` 컬럼 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/Approval.php` | 수정 |
| `app/Services/ApprovalService.php` | 수정 |
| `app/Services/DocumentService.php` | 수정 |
| `database/migrations/..._add_linkable_to_approvals_table.php` | 신규 생성 |
---
## 6. `🔧 수정` [process] 공정단계 options 컬럼 추가
**커밋**: `1f7f45e` | **유형**: feat (기존 테이블 확장)
### 배경
공정단계별 검사 설정/범위 등 확장 속성을 저장할 JSON 컬럼 필요.
### 구현 내용
- `ProcessStep` 모델에 `options` JSON 컬럼 추가 (fillable, cast)
- Store/UpdateProcessStepRequest에 `inspection_setting`, `inspection_scope` 검증 규칙
- `process_steps` 테이블 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php` | 수정 |
| `app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php` | 수정 |
| `app/Models/ProcessStep.php` | 수정 |
| `database/migrations/..._add_options_to_process_steps_table.php` | 신규 생성 |
---
## 7. `🔄 리팩토링` [production] 셔터박스 prefix isStandard 파라미터 제거
**커밋**: `d4f21f0` | **유형**: refactor
### 배경
CF/CL/CP/CB 품목이 모든 길이에 등록되어 boxSize와 무관하게 적용됨. isStandard 분기가 불필요.
### 구현 내용
- `resolveShutterBoxPrefix()`에서 `isStandard` 파라미터 및 분기 로직 제거
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/Production/BendingInfoBuilder.php` | 수정 |
| `app/Services/Production/PrefixResolver.php` | 수정 |
---
## 8. `🔧 수정` [production] 자재투입 replace 모드 지원
**커밋**: `7432fb1` | **유형**: feat (기존 기능 확장)
### 배경
자재투입 시 기존 투입 데이터를 교체하는 방식 선택 가능하도록 지원.
### 구현 내용
- `registerMaterialInputForItem``replace` 파라미터 추가
- Controller에서 request body의 `replace` 값 전달
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/WorkOrderController.php` | 수정 |
---
## 9. `🔄 리팩토링` [core] 모델 스코프 적용 규칙 추가
**커밋**: `9b8cdfa` | **유형**: refactor
### 배경
`where` 하드코딩 대신 모델에 정의된 스코프를 우선 사용하도록 코드 규칙 명시.
### 구현 내용
- `RecordStorageUsage` — where 하드코딩 → `Tenant::active()` 스코프
- `CLAUDE.md` — 쿼리 수정 시 모델 스코프 우선 규칙 명시
### 변경 파일
| 파일 | 작업 |
|------|------|
| `CLAUDE.md` | 수정 |
| `app/Console/Commands/RecordStorageUsage.php` | 수정 |
---
## 10. `⚙️ 설정` [infra] Slack 알림 채널 분리
**커밋**: `3d4dd9f` | **유형**: chore
### 배경
배포 알림 채널을 product_infra에서 deploy_api로 분리하여 알림 관리 개선.
### 구현 내용
- Jenkinsfile Slack 알림 채널 `product_infra``deploy_api` 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `Jenkinsfile` | 수정 |
---
## 11. `🔧 수정` [approval] 결재 테이블 확장 (3건)
**커밋**: `ac72487`, `558a393`, `ce1f910` | **유형**: feat (기존 테이블 확장)
### 배경
결재 시스템에 기안자 읽음 확인, 재상신 횟수, 반려 이력 추적 기능 필요.
### 구현 내용
- `drafter_read_at` 컬럼 — 기안자 완료 결과 확인 타임스탬프 (미읽음 뱃지 지원)
- `resubmit_count` 컬럼 — 재상신 횟수 추적
- `rejection_history` JSON 컬럼 — 반려 이력 저장
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_drafter_read_at_to_approvals_table.php` | 신규 생성 |
| `database/migrations/..._add_resubmit_count_to_approvals_table.php` | 신규 생성 |
| `database/migrations/..._add_rejection_history_to_approvals_table.php` | 신규 생성 |
---
## 12. `🆕 신규` [rd] CM송 저장 테이블 마이그레이션
**커밋**: `66d1004` | **유형**: feat
### 배경
AI 생성 CM송(광고 음악) 데이터 저장을 위한 테이블 필요.
### 구현 내용
- `cm_songs` 테이블 생성 — tenant_id, user_id, company_name, industry, lyrics, audio_path, options
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_05_170000_create_cm_songs_table.php` | 신규 생성 |
---
## 13. `🆕 신규` [approval] 결재양식 마이그레이션 (3건)
**커밋**: `f41605c`, `0f25a5d`, `846ced3` | **유형**: feat
### 배경
전자결재에 재직증명서, 경력증명서, 위촉증명서 양식 추가 필요.
### 구현 내용
- `employment_cert` — 재직증명서 양식 등록
- `career_cert` — 경력증명서 양식 등록
- `appointment_cert` — 위촉증명서 양식 등록
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_05_184507_add_employment_cert_form.php` | 신규 생성 |
| `database/migrations/2026_03_05_230000_add_career_cert_form.php` | 신규 생성 |
| `database/migrations/2026_03_05_234000_add_appointment_cert_form.php` | 신규 생성 |
---
## 14. `🔧 수정` [bill,loan] 어음 V8 확장 필드 및 가지급금 상품권 카테고리
**커밋**: `8c9f2fc` | **유형**: feat (기존 모델 대규모 확장)
### 배경
어음 관리에 V8 규격(증권종류, 할인, 배서, 추심, 개서, 부도 등) 54개 필드 지원 필요. 가지급금에 상품권 카테고리 및 상태(보유/사용/폐기) 관리 필요.
### 구현 내용
- `Bill` 모델 — V8 확장 필드 54개 추가, 수취/발행 어음·수표별 세분화된 상태 체계
- `BillService``assignV8Fields`/`syncInstallments` 헬퍼, instrument_type/medium 필터
- `BillInstallment` — type/counterparty 필드 추가
- `Loan` 모델 — holding/used/disposed 상태 + metadata(JSON) 필드
- `LoanService` — 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding)
- FormRequest — V8 확장 필드 검증 규칙
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/Bill.php` | 수정 (대규모) |
| `app/Models/Tenants/BillInstallment.php` | 수정 |
| `app/Models/Tenants/Loan.php` | 수정 |
| `app/Services/BillService.php` | 수정 |
| `app/Services/LoanService.php` | 수정 |
| `app/Http/Requests/V1/Bill/StoreBillRequest.php` | 수정 |
| `app/Http/Requests/V1/Bill/UpdateBillRequest.php` | 수정 |
| `app/Http/Requests/Loan/LoanStoreRequest.php` | 수정 |
| `app/Http/Requests/Loan/LoanUpdateRequest.php` | 수정 |
| `app/Http/Requests/Loan/LoanIndexRequest.php` | 수정 |
| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 |
| `database/migrations/..._add_v8_fields_to_bills_table.php` | 신규 생성 |
| `database/migrations/..._add_metadata_to_loans_table.php` | 신규 생성 |
---
## 15. `🆕 신규` [loan] 상품권 접대비 자동 연동 + `🔧 수정` 후속 수정 (5건)
**커밋**: `31d2f08`, `03f86f3`, `652ac3d`, `7fe856f`, `c57e768` | **유형**: feat + fix
### 배경
상품권이 사용+접대비해당일 경우 expense_accounts에 자동으로 접대비 레코드를 생성/삭제해야 함. 관련 집계 및 수정/삭제 정책도 정비.
### 구현 내용
- `ExpenseAccount``loan_id` 필드 + `SUB_TYPE_GIFT_CERTIFICATE` 상수 추가
- `LoanService` — 상품권 used+접대비해당 시 expense_accounts 자동 upsert/삭제 (🆕)
- store()에서도 접대비 자동 연동 호출 (🔧)
- `getCategoryBreakdown` — used/disposed 상품권은 가지급금 집계에서 제외 (🔧)
- dashboard summary/목록에서도 used/disposed 상품권 제외 (🔧)
- `isEditable()`/`isDeletable()` — 상품권이면 상태 무관하게 허용 (🔧)
- 접대비 연동 시 `receipt_no`에 시리얼번호 매핑 (🔧)
- `expense_accounts``loan_id` 컬럼 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Tenants/ExpenseAccount.php` | 수정 |
| `app/Models/Tenants/Loan.php` | 수정 |
| `app/Services/LoanService.php` | 수정 (다회) |
| `database/migrations/..._add_loan_id_to_expense_accounts_table.php` | 신규 생성 |
---
## 16. `🆕 신규` [생산지시] 전용 API 엔드포인트 신규 생성 + `🔧 수정` 후속 수정 (4건)
**커밋**: `2df8ecf`, `59d13ee`, `38c2402`, `0aa0a85` | **유형**: feat + fix
### 배경
수주 기반 생산지시 전용 API가 없어 프론트엔드에서 여러 API를 조합해야 했음. 전용 엔드포인트로 통합.
### 구현 내용
- `ProductionOrderService` — 목록(index), 통계(stats), 상세(show) 구현 (🆕)
- Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED)
- `workOrderProgress` 가공 필드, `production_ordered_at` 첫 WO 기반
- BOM 공정 분류 추출 (order_nodes.options.bom_result)
- `ProductionOrderController` + `ProductionOrderIndexRequest` + Swagger 문서 (🆕)
- 날짜 포맷 Y-m-d 변환, `withCount('nodes')` 개소수 추가 (🔧)
- 자재투입 시 WO 자동 상태 전환 (`autoStartWorkOrderOnMaterialInput`) (🆕)
- `process_id=null`인 구매품/서비스 WO 제외 (🔧)
- `extractBomProcessGroups` BOM 파싱 수정 (🔧)
- 재고생산 보조 공정을 일반 워크플로우에서 분리 (`is_auxiliary` 플래그) (🆕)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/ProductionOrderController.php` | 신규 생성 |
| `app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php` | 신규 생성 |
| `app/Services/ProductionOrderService.php` | 신규 생성 + 수정 |
| `app/Swagger/v1/ProductionOrderApi.php` | 신규 생성 |
| `app/Services/WorkOrderService.php` | 수정 |
| `app/Services/OrderService.php` | 수정 |
| `routes/api/v1/production.php` | 수정 |
---
## 17. `🆕 신규` [품질관리] 백엔드 API 구현 + `🔧 수정` 후속 수정 (3건)
**커밋**: `a6e29bc`, `3600c7b`, `0f26ea5` | **유형**: feat + fix
### 배경
품질관리서(제품검사 요청서) 및 실적신고 관리를 위한 백엔드 API 전체 구현.
### 구현 내용
- 품질관리서(quality_documents) CRUD API 14개 엔드포인트 (🆕)
- 실적신고(performance_reports) 관리 API 6개 엔드포인트 (🆕)
- DB 마이그레이션 4개 테이블 (🆕)
- 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개 (🆕)
- 납품일 Y-m-d 포맷 변환, 개소 수 order_nodes 루트 노드 기준 변경 (🔧)
- 수주선택 API에 `client_name` 필드 추가 (🔧)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/QualityDocumentController.php` | 신규 생성 |
| `app/Http/Controllers/Api/V1/PerformanceReportController.php` | 신규 생성 |
| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 신규 생성 |
| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 신규 생성 |
| `app/Http/Requests/Quality/PerformanceReportConfirmRequest.php` | 신규 생성 |
| `app/Http/Requests/Quality/PerformanceReportMemoRequest.php` | 신규 생성 |
| `app/Models/Qualitys/QualityDocument.php` | 신규 생성 |
| `app/Models/Qualitys/QualityDocumentOrder.php` | 신규 생성 |
| `app/Models/Qualitys/QualityDocumentLocation.php` | 신규 생성 |
| `app/Models/Qualitys/PerformanceReport.php` | 신규 생성 |
| `app/Services/QualityDocumentService.php` | 신규 생성 + 수정 |
| `app/Services/PerformanceReportService.php` | 신규 생성 |
| `database/migrations/..._create_quality_documents_table.php` | 신규 생성 |
| `database/migrations/..._create_quality_document_orders_table.php` | 신규 생성 |
| `database/migrations/..._create_quality_document_locations_table.php` | 신규 생성 |
| `database/migrations/..._create_performance_reports_table.php` | 신규 생성 |
| `routes/api/v1/quality.php` | 신규 생성 |

View File

@@ -0,0 +1,287 @@
# 2026-03-06 (금) 백엔드 구현 내역
## 1. `🔧 수정` [생산지시] 보조 공정 WO 카운트 제외
**커밋**: `a845f52` | **유형**: fix (기존 기능 보완)
### 배경
목록 조회 시 `work_orders_count`에 보조 공정(재고생산) WO가 포함되어 공정 진행률이 부정확.
### 구현 내용
- `withCount`에서 `is_auxiliary` WO 제외 조건 추가
- `whereNotNull(process_id)` + `options->is_auxiliary` 조건 적용
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/ProductionOrderService.php` | 수정 |
---
## 2. `🔧 수정` [loan] 상품권 summary에 접대비 집계 추가
**커밋**: `a7973bb` | **유형**: feat (기존 API 확장)
### 배경
상품권 대시보드에서 접대비로 전환된 건수/금액을 별도로 표시해야 함.
### 구현 내용
- `expense_accounts` 테이블에서 접대비(상품권) 건수/금액 조회
- `entertainment_count`, `entertainment_amount` 응답 필드 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/LoanService.php` | 수정 |
---
## 3. `🔧 수정` [receivables] 상위 거래처 집계 soft delete 제외
**커밋**: `be9c1ba` | **유형**: fix (버그 수정)
### 배경
매출채권 상위 거래처 집계 쿼리에서 soft delete된 레코드가 포함되어 금액이 부풀려지는 이슈.
### 구현 내용
- orders, deposits, bills 서브쿼리에 `whereNull('deleted_at')` 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/ReceivablesService.php` | 수정 |
---
## 4. `🆕 신규` [finance] 계정과목 및 일반전표 API 추가
**커밋**: `12d172e` | **유형**: feat
### 배경
회계 시스템의 핵심인 계정과목 관리 및 일반전표(입금/출금/수동전표 통합 목록) API 신규 구현.
### 구현 내용
- `AccountCode` 모델/서비스/컨트롤러 — 계정과목 CRUD
- `JournalEntry`, `JournalEntryLine` 모델 — 전표/전표 분개 모델
- `GeneralJournalEntryService` — 입금/출금/수동전표 UNION 통합 목록, 수동전표 CRUD
- `GeneralJournalEntryController` + FormRequest 검증 클래스
- finance 라우트 등록, i18n 메시지 키 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 신규 생성 |
| `app/Http/Controllers/Api/V1/GeneralJournalEntryController.php` | 신규 생성 |
| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 신규 생성 |
| `app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php` | 신규 생성 |
| `app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php` | 신규 생성 |
| `app/Models/Tenants/AccountCode.php` | 신규 생성 |
| `app/Models/Tenants/JournalEntry.php` | 신규 생성 |
| `app/Models/Tenants/JournalEntryLine.php` | 신규 생성 |
| `app/Services/AccountCodeService.php` | 신규 생성 |
| `app/Services/GeneralJournalEntryService.php` | 신규 생성 |
| `lang/ko/error.php` | 수정 |
| `lang/ko/message.php` | 수정 |
| `routes/api/v1/finance.php` | 수정 |
---
## 5. `🔧 수정` [finance] 일반전표 source 필드 및 페이지네이션 수정
**커밋**: `816c25a` | **유형**: fix (신규 기능 후속 수정)
### 배경
입금/출금 조회 시 source가 CASE WHEN으로 불필요하게 분기되었고, 페이지네이션 응답 구조가 프론트엔드 기대와 불일치.
### 구현 내용
- deposits/withdrawals 조회 시 source를 항상 `'linked'`로 고정
- 페이지네이션 meta 래핑 제거 → 플랫 구조로 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/GeneralJournalEntryService.php` | 수정 |
---
## 6. `🆕 신규` [menu] 즐겨찾기 테이블 마이그레이션
**커밋**: `a67c5d9` | **유형**: feat
### 배경
사용자별 메뉴 즐겨찾기 기능을 위한 데이터 테이블 필요.
### 구현 내용
- `menu_favorites` 테이블 — tenant_id, user_id, menu_id, sort_order
- unique 제약: (tenant_id, user_id, menu_id)
- FK cascade delete: users, menus
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_06_143037_create_menu_favorites_table.php` | 신규 생성 |
---
## 7. `🔧 수정` [departments] options JSON 컬럼 추가
**커밋**: `56e7164` | **유형**: feat (기존 테이블 확장)
### 배경
조직도 숨기기 등 부서별 확장 속성을 저장할 JSON 컬럼 필요.
### 구현 내용
- `departments` 테이블에 `options` JSON 컬럼 추가
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._add_options_to_departments_table.php` | 신규 생성 |
---
## 8. `🆕 신규` [approval] 결재양식 마이그레이션 (6건)
**커밋**: `58fedb0`, `eb28b57`, `c5a0115`, `9d4143a`, `449fce1`, `96def0d` | **유형**: feat
### 배경
전자결재 양식 확대 — 사용인감계, 사직서, 위임장, 이사회의사록, 견적서, 공문서 양식 추가.
### 구현 내용
- `seal_usage` — 사용인감계 양식
- `resignation` — 사직서 양식
- `delegation` — 위임장 양식
- `board_minutes` — 이사회의사록 양식
- `quotation` — 견적서 양식
- `official_letter` — 공문서 양식
- 전체 테넌트에 자동 등록
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_06_100000_add_resignation_form.php` | 신규 생성 |
| `database/migrations/2026_03_06_210000_add_seal_usage_form.php` | 신규 생성 |
| `database/migrations/2026_03_06_230000_add_delegation_form.php` | 신규 생성 |
| `database/migrations/2026_03_06_233000_add_board_minutes_form.php` | 신규 생성 |
| `database/migrations/2026_03_06_235000_add_quotation_form.php` | 신규 생성 |
| `database/migrations/2026_03_07_000000_add_official_letter_form.php` | 신규 생성 |
---
## 9. `🆕 신규` [database] 경조사비 관리 테이블 + 메뉴 추가
**커밋**: `0ea5fa5`, `22160e5` | **유형**: feat
### 배경
거래처 경조사비 관리대장 기능 신규 도입. 데이터 테이블 및 사이드바 메뉴 추가 필요.
### 구현 내용
- `condolence_expenses` 테이블 — 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조), 부조금, 선물, 총금액
- 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가 (중복 방지)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/..._create_condolence_expenses_table.php` | 신규 생성 |
| `database/migrations/..._add_condolence_expenses_menu.php` | 신규 생성 |
---
## 10. `🆕 신규` [문서스냅샷] rendered_html 저장 지원 + Lazy Snapshot API
**커밋**: `293330c`, `5ebf940`, `c5d5b5d` | **유형**: feat + fix
### 배경
문서의 렌더링된 HTML을 스냅샷으로 저장하여 PDF 변환/인쇄 등에 활용. 편집 권한 없이도 스냅샷 갱신 가능한 Lazy Snapshot API 필요.
### 구현 내용
- `Document` 모델 $fillable에 `rendered_html` 추가 (🔧)
- `DocumentService` create/update에서 rendered_html 저장 (🔧)
- Store/Update/UpsertRequest에 `rendered_html` 검증 추가 (🔧)
- `WorkOrderService` 검사문서/작업일지 생성 시 rendered_html 전달 (🔧)
- `PATCH /documents/{id}/snapshot` — canEdit 체크 없이 rendered_html만 업데이트 (🆕)
- `resolveInspectionDocument()``snapshot_document_id` 반환 (🆕)
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Documents/Document.php` | 수정 |
| `app/Services/DocumentService.php` | 수정 |
| `app/Services/WorkOrderService.php` | 수정 |
| `app/Http/Requests/Document/StoreRequest.php` | 수정 |
| `app/Http/Requests/Document/UpdateRequest.php` | 수정 |
| `app/Http/Requests/Document/UpsertRequest.php` | 수정 |
| `app/Http/Controllers/Api/V1/Documents/DocumentController.php` | 수정 |
| `routes/api/v1/documents.php` | 수정 |
---
## 11. `🔧 수정` [품질관리] order_ids 영속성 + location 데이터 저장
**커밋**: `f2eede6` | **유형**: feat (기존 API 확장)
### 배경
품질관리서에 수주 연결 및 개소별 검사 데이터(시공규격, 변경사유, 검사결과)를 저장해야 함.
### 구현 내용
- StoreRequest/UpdateRequest에 `order_ids`, `locations` 검증 추가
- `QualityDocumentLocation``inspection_data`(JSON) fillable/cast 추가
- store()에 `syncOrders` 연동, update()에 `syncOrders` + `updateLocations` 연동
- `inspection_data` 컬럼 추가 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 수정 |
| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 수정 |
| `app/Models/Qualitys/QualityDocumentLocation.php` | 수정 |
| `app/Services/QualityDocumentService.php` | 수정 (대규모) |
| `database/migrations/..._inspection_data_to_quality_document_locations.php` | 신규 생성 |
---
## 12. `🆕 신규` 제품검사 요청서 Document(EAV) 자동생성 및 동기화
**커밋**: `2231c9a` | **유형**: feat
### 배경
품질관리서 생성/수정/수주연결 시 제품검사 요청서 Document를 EAV 방식으로 자동 생성하고 동기화해야 함.
### 구현 내용
- `document_template_sections``description` 컬럼 추가
- `QualityDocumentService``syncRequestDocument()` 메서드 추가
- 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑
- `rendered_html` 초기화 (데이터 변경 시 재캡처 트리거)
- `transformToFrontend``request_document_id` 포함
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Models/Documents/DocumentTemplateSection.php` | 수정 |
| `app/Services/QualityDocumentService.php` | 수정 (대규모) |
| `database/migrations/..._add_description_to_document_template_sections.php` | 신규 생성 |
---
## 13. `⚙️ 설정` [API] logging, docs, seeder 등 부수 정리
**커밋**: `ff85530` | **유형**: chore
### 배경
여러 파일의 경로, 설정, 문서 등 소소한 정리 작업.
### 구현 내용
- `LOGICAL_RELATIONSHIPS.md` 보완 (최신 모델 관계 반영)
- `Legacy5130Calculator` 수정
- `logging.php` 설정 추가
- `KyungdongItemSeeder` 수정
- docs 문서 경로 수정
### 변경 파일
| 파일 | 작업 |
|------|------|
| `LOGICAL_RELATIONSHIPS.md` | 수정 |
| `app/Helpers/Legacy5130Calculator.php` | 수정 |
| `config/logging.php` | 수정 |
| `database/seeders/Kyungdong/KyungdongItemSeeder.php` | 수정 |
| `docs/INDEX.md` | 수정 |

View File

@@ -0,0 +1,40 @@
# 2026-03-07 (토) 백엔드 구현 내역
## 1. `🆕 신규` [approval] 연차사용촉진 통지서 1차/2차 양식 마이그레이션
**커밋**: `ad93743` | **유형**: feat
### 배경
근로기준법에 따른 연차사용촉진 통지서(1차/2차) 양식을 전자결재 시스템에 등록 필요.
### 구현 내용
- `leave_promotion_1st` — 연차사용촉진 통지서 (1차) 양식, hr 카테고리
- `leave_promotion_2nd` — 연차사용촉진 통지서 (2차) 양식, hr 카테고리
- 전체 테넌트에 자동 등록
### 변경 파일
| 파일 | 작업 |
|------|------|
| `database/migrations/2026_03_07_100000_add_leave_promotion_forms.php` | 신규 생성 |
---
## 2. `🔧 수정` [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선
**커밋**: `3ac64d5` | **유형**: feat (기존 API 확장)
### 배경
품질관리서 작성 시 수주 선택 API에 거래처/품목 필터가 없고, 개소별 상세 데이터 부족. 검사 상태 판별 로직도 개선 필요.
### 구현 내용
- `availableOrders``client_id`/`item_id` 필터 파라미터 지원
- 응답에 `client_id`, `client_name`, `item_id`, `item_name`, `locations`(개소 상세) 추가
- `show` — 개소별 데이터에 거래처/모델 정보 포함
- `DocumentService``fqcStatus`를 rootNodes 기반으로 변경
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Services/QualityDocumentService.php` | 수정 |
| `app/Services/DocumentService.php` | 수정 |
| `LOGICAL_RELATIONSHIPS.md` | 수정 |

View File

@@ -0,0 +1,47 @@
# 2026-03-08 (일) 백엔드 구현 내역
## 1. `🔧 수정` [finance] 계정과목 확장 및 전표 연동 시스템 구현
**커밋**: `0044779` | **유형**: feat (3/6 신규 기능 대규모 확장)
### 배경
3/6에 추가한 계정과목/일반전표 기본 API를 확장하여 기본 계정과목 시딩, 전표 자동 연동(카드거래/세금계산서), 계정과목 업데이트 기능 구현.
### 구현 내용
#### 계정과목 확장 (🔧 기존 확장)
- `AccountCode` 모델 확장 — 관계, 스코프, 헬퍼 추가
- `AccountCodeService` 확장 — 업데이트, 트리 조회, 기본 계정과목 시딩 로직
- `UpdateAccountSubjectRequest` 신규 — 업데이트 검증 규칙
- `StoreAccountSubjectRequest` — 추가 검증 규칙 보강
#### 전표 자동 연동 (🆕 신규)
- `JournalSyncService` 신규 — 카드거래/세금계산서 → 전표 자동 생성 서비스
- `SyncsExpenseAccounts` 트레이트 — 경비계정 동기화 공통 로직
- `CardTransactionController` 확장 — 전표 연동 엔드포인트 추가
- `TaxInvoiceController` 확장 — 전표 연동 엔드포인트 추가
#### 데이터베이스 (🆕 신규)
- `expense_accounts` 테이블에 전표 연결 컬럼 마이그레이션 (journal_entry_id 등)
- `account_codes` 테이블 확장 마이그레이션 (추가 속성 컬럼)
- 전체 테넌트 기본 계정과목 시딩 마이그레이션
### 변경 파일
| 파일 | 작업 |
|------|------|
| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 수정 |
| `app/Http/Controllers/Api/V1/CardTransactionController.php` | 수정 (대규모) |
| `app/Http/Controllers/Api/V1/TaxInvoiceController.php` | 수정 (대규모) |
| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 수정 |
| `app/Http/Requests/V1/AccountSubject/UpdateAccountSubjectRequest.php` | 신규 생성 |
| `app/Models/Tenants/AccountCode.php` | 수정 |
| `app/Models/Tenants/ExpenseAccount.php` | 수정 |
| `app/Models/Tenants/JournalEntry.php` | 수정 |
| `app/Services/AccountCodeService.php` | 수정 (대규모) |
| `app/Services/GeneralJournalEntryService.php` | 수정 |
| `app/Services/JournalSyncService.php` | 신규 생성 |
| `app/Traits/SyncsExpenseAccounts.php` | 신규 생성 |
| `database/migrations/..._add_journal_link_to_expense_accounts_table.php` | 신규 생성 |
| `database/migrations/..._enhance_account_codes_table.php` | 신규 생성 |
| `database/migrations/..._seed_default_account_codes_for_all_tenants.php` | 신규 생성 |
| `routes/api/v1/finance.php` | 수정 |

View File

@@ -0,0 +1,72 @@
# SAM API 백엔드 구현 내역서
## 2026년 3월 1주차 (3/2 ~ 3/8)
**83개 커밋**, 7일간 구현 내역
### 태그 범례
| 태그 | 의미 |
|------|------|
| `🆕 신규` | 새로운 기능/API/테이블 생성 |
| `🔧 수정` | 기존 기능 버그 수정, 확장, 보완 |
| `🔄 리팩토링` | 기능 변경 없이 코드 구조 개선 |
| `⚙️ 설정` | 환경 설정, 인프라, 문서 정리 |
### 날짜별 문서
| 날짜 | 파일 | 주요 작업 | 🆕 | 🔧 | 🔄 | ⚙️ |
|------|------|-----------|-----|-----|-----|-----|
| 3/2 (월) | [2026-03-02_구현내역.md](./2026-03-02_구현내역.md) | 로드맵 테이블, AI 견적 엔진 | 2 | - | - | - |
| 3/3 (화) | [2026-03-03_구현내역.md](./2026-03-03_구현내역.md) | Gemini 업그레이드, 배포 수정, HR 확장, 자재투입 개선 | - | 7 | - | 1 |
| 3/4 (수) | [2026-03-04_구현내역.md](./2026-03-04_구현내역.md) | 바로빌 연동, 리스크 대시보드, 지출결의서, 배차 시스템 | 6 | 9 | - | - |
| 3/5 (목) | [2026-03-05_구현내역.md](./2026-03-05_구현내역.md) | CEO 대시보드, 어음 V8, 상품권 접대비, 생산지시, 품질관리 | 7 | 7 | 2 | 1 |
| 3/6 (금) | [2026-03-06_구현내역.md](./2026-03-06_구현내역.md) | 계정과목/일반전표, 문서 스냅샷, 결재양식 6종, 경조사비 | 7 | 5 | - | 1 |
| 3/7 (토) | [2026-03-07_구현내역.md](./2026-03-07_구현내역.md) | 연차촉진 통지서, 품질검사 필터링 | 1 | 1 | - | - |
| 3/8 (일) | [2026-03-08_구현내역.md](./2026-03-08_구현내역.md) | 계정과목 확장, 전표 연동 시스템 | - | 1 | - | - |
| **합계** | | | **23** | **30** | **2** | **3** |
### 도메인별 주요 기능
#### 재무/회계
- 🆕 계정과목 및 일반전표 API 신규 구축
- 🆕 전표 자동 연동 (카드거래/세금계산서)
- 🆕 접대비 상세 조회 API + 리스크 감지
- 🆕 부가세 상세 조회 API
- 🆕 경조사비 관리 테이블
- 🆕 바로빌 연동 API
- 🔧 접대비/복리후생비 리스크 감지형 대시보드 전환
- 🔧 매출채권 상세 대시보드 개선
- 🔧 가지급금 카테고리 분류 (카드/경조사/상품권/접대비)
- 🔧 상품권 접대비 자동 연동
- 🔧 어음 V8 확장 필드 (54개)
#### 생산/품질
- 🆕 생산지시 전용 API (목록/통계/상세)
- 🆕 품질관리서 CRUD API (14개 엔드포인트)
- 🆕 실적신고 관리 API (6개 엔드포인트)
- 🆕 제품검사 요청서 EAV 자동생성
- 🆕 보조 공정(재고생산) 분리
- 🔧 절곡 검사 데이터 복제/EAV 변환
- 🔧 자재투입 bom_group_key/replace 모드
#### 전자결재
- 🆕 Document ↔ Approval 브릿지 연동
- 🆕 결재양식 11종 추가 (지출결의서, 근태신청, 사유서, 재직증명서 등)
- 🔧 drafter_read_at, resubmit_count, rejection_history 컬럼
#### 대시보드/리포트
- 🆕 CEO 대시보드 6개 섹션 API
- 🆕 일일보고서 엑셀 내보내기
- 🔧 자금현황 카드 필드
#### 출고/배차
- 🆕 배차정보 다중 행 시스템
- 🆕 배차차량 관리 API
#### 인프라/기타
- ⚙️ Gemini 2.5-flash 업그레이드
- 🔧 .env 권한 640 보장 (배포)
- ⚙️ Slack 알림 채널 분리
- 🆕 문서 rendered_html 스냅샷 API
- 🆕 메뉴 즐겨찾기 테이블
- 🔧 주소 필드 500자 확장

View File

@@ -0,0 +1,213 @@
# CEO 대시보드 수정계획서 (최종)
**작성일**: 2026-03-09
**기반 문서**: `[QA-2026-03-09] ceo-dashboard-ui-verification.md`
**검증 수준**: 화면(Chrome DevTools) + 프론트엔드 코드 + 백엔드 코드 + 실제 데이터 전부 확인 완료
---
## 최종 이슈 요약
| 분류 | 건수 | 내용 |
|------|------|------|
| 🟡 백엔드 개선 | 1건 | 현황판/채권추심 sub_label 필드 추가 |
| 🟢 프론트엔드 개선 | 2건 | 더미값 제거, 매입 라벨 명확화 |
| ✅ 수정 불필요 (오진 정정) | 8건 | 아래 상세 표 참조 |
---
## 1. 수정 필요 항목
### B3. 현황판/채권추심 거래처 sub_label 필드 추가 🟡
**현상**: 프론트엔드에서 하드코딩된 더미 거래처명 사용 중
| 위치 | 더미값 | TODO 주석 |
|------|--------|----------|
| `transformers/status-issue.ts:20-29` | "주식회사 부산화학 외" 등 | ✅ 있음 (line 18) |
| `transformers/receivable.ts:96-103` | "(주)부산화학 외" 등 | ✅ 있음 (line 96) |
**백엔드 수정 내용**:
1. **`StatusBoardService.php`** — 각 항목 응답에 `sub_label` 필드 추가
- `getBadDebtStatus()` (line 68-83): 최다 금액 거래처 실제 이름 조회
- `getNewClientStatus()`: 최근 등록 업체명 조회
- 기타 항목도 해당 시 sub_label 제공
2. **`BadDebtService.php`** — `summary()` 응답에 per-card 거래처 정보 추가
- `top_client_name`: 누적 악성채권 최다 금액 거래처명
- 카드별 `sub_label`: 해당 카테고리 최다 금액 거래처명 + 건수
**프론트엔드 후속 작업**: B3 완료 후 → F1 (더미값 제거)
---
### F1. 더미 거래처명 제거 (B3 완료 후) 🟢
**대상 파일**:
- `src/lib/api/dashboard/transformers/status-issue.ts`
- Line 20-29: `STATUS_BOARD_FALLBACK_SUB_LABELS` 상수 제거
- Line 35-53: `buildStatusSubLabel()` → API `sub_label` 직접 사용
- `src/lib/api/dashboard/transformers/receivable.ts`
- Line 98-103: `DEBT_COLLECTION_FALLBACK_SUB_LABELS` 상수 제거
- Line 109-121: `buildDebtSubLabel()` → API 제공 값 사용
---
### F3. 매입 섹션 "당월" 컨텍스트 명확화 🟢
**현상**:
- 섹션 subtitle: "당월 매입 실적" + Badge: "당월"
- 카드 라벨: "누적 매입" (line 65 — 이것 자체는 정확)
- **실제 데이터**: `cumulative_purchase` = 연간 누적 (2026-01-01 ~ 오늘, Feb 포함)
- 일별 매입 내역: 2026-02-27 거래 표시 → "당월 매입 내역" 제목 하에 전월 데이터 포함
**코드 확인**:
- `PurchaseStatusSection.tsx:50``subtitle="당월 매입 실적"`
- `PurchaseStatusSection.tsx:53``<Badge>당월</Badge>`
- `PurchaseStatusSection.tsx:65``<span>누적 매입</span>`
- `DashboardCeoService.php:175-180``whereYear('purchase_date', $year)` = 연간 누적
**수정 방향**:
- subtitle: "당월 매입 실적" → "매입 실적" 또는 "연간 매입 현황"
- Badge: "당월" → 제거 또는 "YTD"로 변경
- 하단 카드 title: "당월 매입 내역" → "최근 매입 내역"
---
## 2. 수정 불필요 항목 (최종 정리)
### 1차 QA → 2차 검증 → 최종 검토로 순차 정정된 이슈들
| # | 이전 보고 | 최종 검증 결과 | 검증 근거 |
|---|----------|-------------|----------|
| ~~C1~~ | 5개 섹션 본문 미렌더링 | **LazySection 정상** — 스크롤 시 로드 | `LazySection.tsx` IntersectionObserver + DOM 확인 |
| ~~C2~~ | 매출 금액 10배 차이 | **NavBar=누적, 본문=당월 구분 표시** | `SalesStatusSection.tsx:63` "누적 매출" 라벨 확인 |
| ~~C3~~ | 발행어음 데이터 불일치 | **다른 테이블 (설계 의도)** | `expected_expenses` 테이블(예측) ≠ `bills` 테이블(실제) |
| ~~C4~~ | 채권추심 건수 불일치 | **다른 산출 기준 (설계 의도)** | StatusBoard=레코드 7건(status=collecting) vs BadDebt=거래처 5곳(distinct client_id) |
| ~~I2~~ | 카드 월별 합계 0원 버그 | **정상 — 해당 월 데이터 없음** | 카드 거래 20건 모두 2025-01~2026-01-28, 2/3월 거래 0건 |
| ~~I3~~ | 미수금 산출 기준 차이 | **다른 산출 방식 (설계 의도)** | 화면 확인 |
| ~~M1~~ | 가지급금 카드 금액 차이 | **다른 기준 (설계 의도)** | 가지급금 전환 기준 vs 카드 사용 총액 |
| ~~발주~~ | 현황판 "발주" 미표시 | **의도적 숨김** | `STATUS_BOARD_HIDDEN_ITEMS.has('purchases')` (2026-03-03 비활성화) |
### 상세 정정 사항
#### ~~B1: 카드 월별 합계 0원~~ — SQL 버그 아님 ✅
**이전 판단**: `DB::raw('DATE(COALESCE(used_at, withdrawal_date))')` SQL 버그
**최종 판단**: **데이터가 없어서 0이 정상**
```
카드 거래 20건 날짜 분포:
- 가장 오래된 거래: 2025-01-12 (used_at=NULL, withdrawal_date만)
- 가장 최근 거래: 2026-01-28 (used_at='2026-01-28')
- 2026-02 거래: 0건
- 2026-03 거래: 0건
→ current_month_total=0, previous_month_total=0 모두 정확
```
**QA 오진 원인**: 카드사용내역 리스트 페이지에서 "15건 표시"를 보고 당월/전월에도 데이터가 있을 것으로 추정했으나, 리스트는 전체 기간 데이터를 표시.
**참고**: `summary()` 메서드의 SQL 패턴(`DB::raw COALESCE`)이 `index()` 메서드(`whereDate + orWhere`)와 다르지만, 현재 데이터에서는 정상 작동 확인. 향후 코드 일관성을 위해 패턴 통일은 권장하나, 긴급하지 않음.
#### ~~B2: 현황판 vs 채권추심 건수~~ — 설계 의도 ✅
**이전 판단**: 건수 통일 필요
**최종 판단**: **의도적으로 다른 관점 제공**
| API | 쿼리 | 의미 |
|-----|------|------|
| `StatusBoardService.php:72` | `where('status', 'collecting')->count()` | "지금 추심중인 건" = 7건 |
| `BadDebtService.php:107-111` | `distinct('client_id')->count('client_id')` | "악성채권이 있는 거래처 수" = 5곳 |
현황판은 "현재 추심 진행 중인 건수"를, 채권추심 본문은 "악성채권 보유 거래처 수"를 보여주는 것으로, 각각 다른 관점의 지표임. 사용자가 혼동할 수 있으나, 정보 제공 목적이 다름.
#### ~~B5: 매입 "당월" 기간~~ — 백엔드 정상, 프론트 라벨 이슈 ✅
`DashboardCeoService.php:175-180``cumulative_purchase``whereYear(2026)` = 연간 누적으로 정확히 산출. "당월" 라벨은 프론트엔드 이슈 → F3으로 처리.
---
## 3. 수정 우선순위
| 순위 | 이슈 | 영역 | 난이도 | 비고 |
|------|------|------|--------|------|
| 1 | B3: sub_label 필드 추가 | 백엔드 | 중 | 거래처 조회 쿼리 추가 |
| 2 | F1: 더미 거래처명 제거 | 프론트 | 하 | B3 완료 후 상수/함수 제거 |
| 3 | F3: 매입 섹션 라벨 명확화 | 프론트 | 하 | 텍스트만 변경 |
---
## 4. 수정 후 재검수 계획
| 단계 | 항목 | 검증 방법 |
|------|------|----------|
| 1 | B3+F1 수정 후: 거래처명 | 현황판/채권추심에 실제 거래처명 표시 확인 |
| 2 | F3 수정 후: 매입 라벨 | "당월" 대신 적절한 기간 표현 확인 |
| 3 | 전체: 상세 모달 | 각 섹션 모달 열기/닫기/날짜필터 동작 확인 |
---
## 부록: 관련 파일 위치
### 백엔드 (sam-api)
| 파일 | 이슈 | 상태 |
|------|------|------|
| `app/Services/StatusBoardService.php:68-83` | B3 (sub_label 추가) | 수정 필요 |
| `app/Services/BadDebtService.php:107-111` | B3 (per-card 거래처) | 수정 필요 |
| `app/Services/CardTransactionService.php:109-153` | ~~B1~~ | ✅ 정상 (데이터 없음이 원인) |
| `app/Services/ExpectedExpenseService.php:241-301` | ~~B4~~ | ✅ 정상 (다른 테이블, 설계 의도) |
| `app/Services/DashboardCeoService.php:166-234` | ~~B5~~ | ✅ 정상 (프론트 라벨만 수정) |
### 프론트엔드 (sam-react-prod)
| 파일 | 이슈 | 상태 |
|------|------|------|
| `src/lib/api/dashboard/transformers/status-issue.ts:18-53` | F1 (더미 sub_label) | 수정 필요 (B3 후) |
| `src/lib/api/dashboard/transformers/receivable.ts:96-121` | F1 (더미 sub_label) | 수정 필요 (B3 후) |
| `src/components/business/CEODashboard/sections/PurchaseStatusSection.tsx:50-53,161` | F3 (라벨) | 수정 필요 |
| `src/components/business/CEODashboard/LazySection.tsx` | ~~C1~~ | ✅ 정상 |
| `src/components/business/CEODashboard/sections/SalesStatusSection.tsx:63` | ~~F2~~ | ✅ 정상 ("누적 매출" 명확) |
---
## 5. 하단 섹션 추가 검증 결과 (3차)
### 생산/출고/시공/근태 4개 섹션 소스 페이지 대조 + 코드 검증
| 섹션 | 대시보드 | 소스 페이지 | API 엔드포인트 | 결론 |
|------|---------|-----------|-------------|------|
| 생산 현황 | 0공정 | 작업지시 39건 (모두 2월/작업대기) | `dashboard/production/summary` | ✅ 정확 (오늘 예정 없음) |
| 출고 현황 | 0건/0원 | 당일출고 0건, 전체 8건 (12~1월) | (생산과 동일 API) | ✅ 정확 (당월 건 없음) |
| 시공 현황 | 0건 | 시공진행 7/완료 4 (**Mock 데이터**) | `dashboard/construction/summary` | ✅ 비교불가 (소스=Mock) |
| 근태 현황 | 0명 전부 | 미출근 55명/출근 0명 | `dashboard/attendance/summary` | ✅ 설계 차이 (기록 vs 명부) |
### 참고 사항 (향후 개선 검토)
1. **시공 현황 — NULL end_date 처리** (`DashboardCeoService.php:555-567`)
- 현재 쿼리: `contract_end_date >= $monthEnd` 조건에서 NULL 제외됨
- 실제 계약 데이터 투입 시, 진행 중(`end_date IS NULL`) 계약이 대시보드에 미표시될 수 있음
- 권장: `orWhere(fn($q) => $q->where('start_date', '<=', $monthEnd)->whereNull('end_date'))` 조건 추가 검토
2. **시공관리 페이지 — API 미연동** (`construction/management/actions.ts`)
- `TODO: 실제 API 연동 시 구현` 주석 → 현재 전체가 Mock 데이터
- 대시보드 시공 섹션은 실제 `contracts` 테이블 조회 → 소스 페이지와의 정합성 검증은 API 연동 완료 후 재실시
3. **근태 대시보드 — "미출근" 미표시**
- 대시보드는 `attendances` 테이블 레코드 기반 → 출근 기록 없으면 모두 0
- 근태관리 페이지는 사원 명부 기반 → "미출근 55명" 표시
- CEO 관점에서 "미출근" 정보가 필요한지는 비즈니스 결정 사항
---
## 검증 이력
| 단계 | 내용 | 결과 |
|------|------|------|
| 1차 QA | 화면 검수 (18개 섹션) | Critical 4건 + Important 3건 보고 |
| 2차 검증 | LazySection + API 응답 확인 | Critical 4건 → 전부 정정 (버그 아님) |
| 3차 검토 | 백엔드 코드 + 실제 DB 데이터 확인 | Important 3건 중 1건(카드 0원) 추가 정정 |
| 4차 추가 검증 | 하단 4개 섹션 소스 대조 + 코드 검증 | 4건 모두 정상 (참고 3건 기록) |
| **최종 결론** | **실제 수정 필요: 3건** (백엔드 1 + 프론트 2) | 나머지 모두 수정 불필요 확인 |

View File

@@ -0,0 +1,252 @@
# CEO 대시보드 UI 검수 결과 (2차 검증 포함)
**작성일**: 2026-03-09
**목적**: 대시보드 전체 18개 섹션의 API 데이터 정합성 및 연동 검증
**방법**: 화면 검수 (Chrome DevTools MCP로 실제 화면 조작 + DOM 검증)
---
## 검수 범위 요약
| 구분 | 수량 | 비고 |
|------|------|------|
| 대시보드 카드 섹션 | 18개 | SummaryNavBar 기준 |
| 본문 렌더링 | **18개 전부** | LazySection으로 스크롤 시 로드 (2차 검증) |
| 상세 모달 | 10개 | 날짜필터 포함 |
| Mock 섹션 (제외) | 2개 | 일별 매출/매입 내역 |
---
## Phase 1: 카드 수치 표출 확인 ✅ 완료
대시보드 로드 후 각 카드에 표시된 수치를 기록.
| # | 섹션 | SummaryNavBar 값 | 본문 카드 | 확인 |
|---|------|-----------------|----------|------|
| 1 | 오늘의 이슈 | 3건 | ✅ 렌더링 | - [x] |
| 2 | 자금현황 | 0원 | ✅ 렌더링 (미수금 9억4,697만 / 미지급금 1억5,944만) | - [x] |
| 3 | 현황판 | 7항목 | ✅ 렌더링 (수주0/채권추심7/안전재고833/세금신고-/신규업체45/연차0/결재1) | - [x] |
| 4 | 당월 예상 지출 | 1억 | ✅ 렌더링 (매입0/카드0/발행어음1억) | - [x] |
| 5 | 가지급금 현황 | 1,150만 | ✅ 렌더링 (카드1,150만/경조사0/상품권0/접대비0) | - [x] |
| 6 | 접대비 현황 | 0원 | ✅ 렌더링 (리스크 항목 4개 모두 0) | - [x] |
| 7 | 복리후생비 현황 | 40만 | ✅ 렌더링 (리스크 항목: 사적사용20만1건/특정인편중20만1건) | - [x] |
| 8 | 미수금 현황 | 9.4억 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
| 9 | 채권추심 현황 | 1.2억 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
| 10 | 부가세 현황 | 0원 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
| 11 | 캘린더 | 26일정 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
| 12 | 매출 현황 | 1.1억 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
| 13 | 매입 현황 | 165만 | ✅ 렌더링 (당월 누적매입165만 / 미결제165만 / 차트+테이블) | - [x] |
| 14 | 생산 현황 | 0공정 | ✅ 렌더링 (작업지시 없음) | - [x] |
| 15 | 출고 현황 | 0건 | ✅ 렌더링 (7일이내0 / 30일이내0) | - [x] |
| 16 | 미출고 내역 | 6건 | ✅ 렌더링 (6건 상세목록 표시) | - [x] |
| 17 | 시공 현황 | 0건 | ✅ 렌더링 (시공진행0/시공완료0) | - [x] |
| 18 | 근태 현황 | 0명 | ✅ 렌더링 (출근0/휴가0/지각0/결근0) | - [x] |
### 2차 검증: LazySection 확인 (1차 QA 오류 정정)
1차 QA에서 "본문 미렌더링"으로 보고된 5개 섹션(미수금/채권추심/부가세/캘린더/매출)은 실제로는 **LazySection**(IntersectionObserver 기반 lazy loading)으로 정상 작동합니다. 스크롤하여 뷰포트에 진입하면 콘텐츠가 로드됩니다.
**확인 방법**:
- DOM 검사: `[data-section-key]` 18개 전부 존재 확인
- 스크롤 후 콘텐츠 확인: 5개 섹션 모두 데이터 정상 렌더링
- LazySection.tsx 분석: IntersectionObserver + rootMargin='300px' 패턴
**스크롤 후 확인된 본문 데이터**:
| 섹션 | 본문 주요 수치 | NavBar 값 | 일치 |
|------|--------------|----------|------|
| 미수금 | 누적 미수금 9억 4,164만 / 미수금 거래처 79건 / 연체 1건 / 악성채권 11건 | 9.4억 | ✅ |
| 채권추심 | 누적 악성채권 1억 1,869만 / (주)부산화학 외 4건 | 1.2억 | ✅ |
| 부가세 | 매출세액 0원 / 매입세액 0원 / 예상 납부세액 0원 / 미발행 1건 | 0원 | ✅ |
| 캘린더 | 2026년 3월 전체 일정 표시 | 26일정 | ✅ |
| 매출 | 당월누적 매출 1억 673만 / 달성률 6% / 전년대비 -93.6% / 당월 매출 1,045만 | 1.1억 | ✅ |
---
## Phase 2: 상세 모달 + 날짜필터 검증
### 2-4. 복리후생비 상세 모달 ✅ (검증 완료)
| 테스트 | 방법 | 확인 |
|--------|------|------|
| 모달 열기 | 카드 클릭 → 요약/차트/테이블 확인 | - [x] 완료 |
| 당월 날짜필터 | 당월 → 데이터 있음 (1건 200,000) | - [x] 완료 |
| 전월 날짜필터 | 전월 → 데이터 없음 (0건) | - [x] 완료 |
### 나머지 모달 (Phase 2)
> 당월 예상 지출, 가지급금, 접대비 등 나머지 모달은 하단 수정계획에 따라 이슈 수정 후 재검수 예정.
---
## Phase 3: 소스 페이지 ↔ 대시보드 데이터 연동 검증 ✅ 완료
### 3-1. 복리후생비 (세금계산서 분개) ✅ 검증 완료
| 테스트 | 소스 페이지 | 결과 | 확인 |
|--------|-----------|------|------|
| 분개 추가 | 세금계산서관리 | 대시보드 금액 변동 확인 (51만→31만) | - [x] ✅ |
| 계정 변경 | 세금계산서관리 | 대시보드 금액 변동 확인 (51만→40만) | - [x] ✅ |
| 날짜필터 | 대시보드 모달 | 전월 변경 → 0건 표시 | - [x] ✅ |
### 3-2. 미수금 현황 ⚠️ 산출 기준 다름
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|--------|---------|-----------|------|
| 미수금 잔액 | 9억 4,697만 | 미수금현황 합계 미수금 = **음수** (-311,979,400) | ⚠️ 산출 기준 불일치 |
> 대시보드의 미수금은 자금현황 카드 내 "미수금 잔액"으로 표시. 미수금현황 페이지의 합계 행은 월별 차이금액의 합산으로 음수 표시. 두 페이지의 산출 기준이 완전히 다름.
### 3-3. 매출 현황 ✅ 정정 (2차 검증)
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|--------|---------|-----------|------|
| 매출 금액 (NavBar) | 1.1억 | cumulative_sales = 106,726,323 (1.07억) | ✅ NavBar는 누적매출 표시 (반올림 1.1억) |
| 매출 금액 (본문) | 당월누적 1억 673만 / 당월 1,045만 | 매출관리 당월 매출 = 10,450,000원 | ✅ 본문에서 구분 표시 |
> **1차 QA 오류 정정**: NavBar "1.1억"은 `cumulative_sales`(누적매출)이며, 본문에서는 "당월누적 매출 1억 673만"과 "당월 매출 1,045만"을 구분 표시. 10배 차이가 아닌 다른 지표 표시.
### 3-4. 매입 현황 ✅ 일치
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|--------|---------|-----------|------|
| 매입 금액 | 165만 | 매입관리 합계 = **1,650,000원** | ✅ 일치 |
> 단, "당월" 라벨이지만 데이터는 2026-02-27 것임 (3월 매입 없음). 라벨 정확성 재검토 필요.
### 3-5. 당월 예상 지출 (발행어음) ⚠️ 소스 확인 필요
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|--------|---------|-----------|------|
| 발행어음 | 1억 | 어음관리 당월 = 수취어음 2건 40,000원 **(발행어음 0건)** | ⚠️ 다른 데이터 소스 |
> 대시보드의 발행어음 1억은 `expected-expenses` API에서 `by_transaction_type.bill.total = 100,000,000`으로 제공. 어음관리 페이지(`bills` 테이블)와 다른 데이터 소스(`expected_expenses` 테이블) 사용. **최종 확인: 설계 의도** — expected_expenses는 수동 입력된 지출 예측 데이터이며, bills는 실제 발행어음 문서. 두 시스템은 독립적.
### 3-6. 가지급금 현황 ⚠️ 기준 다름
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|--------|---------|-----------|------|
| 카드 | 1,150만 | 카드사용내역 당월 합계 ≈ 467만 | ⚠️ 기준 다름 (가지급금 전환 기준) |
| 상품권 | 0원 | 상품권관리 보유 0건/0원 | ✅ 일치 |
> ~~카드사용내역 요약(전월/당월/건수)이 모두 0원/0건으로 표시 — API 버그~~
> **최종 확인: 버그 아님** — 카드 거래 20건의 날짜 범위가 2025-01~2026-01-28이며, 2026년 2월/3월 거래는 0건. 따라서 전월/당월 합계 0원은 정확한 값.
### 3-7. 미출고 내역 ✅ 대시보드 내 확인
| 테스트 | 대시보드 | 결과 |
|--------|---------|------|
| 미출고 | 6건 | 대시보드 카드 내 6건 상세목록 표시 (LOT번호, 현장명, 납기일 포함) | ✅ |
### 3-8. 채권추심 현황 ⚠️ 건수 불일치 + 더미 거래처명
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|--------|---------|-----------|------|
| 금액 | 본문 1억 1,869만 / NavBar 1.2억 | 악성채권 5건 합계 ≈ 1.19억 | ✅ 일치 |
| 건수 (현황판) | 7건 | 악성채권관리 = **5건** | ⚠️ status-board API 별도 산출 |
| 건수 (채권추심 본문) | 5건 (client_count) | 악성채권관리 = 5건 | ✅ 일치 |
| 거래처명 | "(주)부산화학 외 4건" | 실제 거래처 미확인 | ⚠️ **하드코딩 더미값** |
> **2차 검증 발견**: 채권추심 본문/현황판의 거래처명("부산화학", "삼성테크" 등)은 `DEBT_COLLECTION_FALLBACK_SUB_LABELS`와 `STATUS_BOARD_FALLBACK_SUB_LABELS`에 하드코딩된 **더미값**. 코드에 `// TODO: 백엔드 per-card sub_label/count 제공 시 더미값 제거` 주석 있음.
### 3-9. 현황판 "발주" 미표시 ✅ 의도적 숨김 (2차 검증)
> `STATUS_BOARD_HIDDEN_ITEMS`에 `purchases`가 포함되어 의도적으로 숨김 처리. 사용자 설정에서도 `purchase: false`. 백엔드 path 오류 + 데이터 정합성 이슈 해결 전까지 비활성화 (코드 주석: `[2026-03-03] 비활성화`).
---
## 발견된 이슈 요약 (최종 검토 반영)
### 🔴 Critical → 없음 (1차 이슈 모두 정정)
1차 QA의 Critical 이슈 4건은 2차 검증에서 모두 재분류됨:
- ~~C1 (5개 섹션 미렌더링)~~: LazySection 정상 → **이슈 아님**
- ~~C2 (매출 10배 차이)~~: NavBar=누적, 본문=당월 구분 → **이슈 아님**
- ~~C3 (발행어음 불일치)~~: `expected_expenses` 테이블(예측) ≠ `bills` 테이블(실제) → **설계 의도**
- ~~C4 (채권추심 건수)~~: StatusBoard=레코드 7건 vs BadDebt=거래처 5곳 → **설계 의도**
### 🟡 Important (실제 수정 필요: 3건)
| # | 이슈 | 상세 | 조치 |
|---|------|------|------|
| I1 | **채권추심/현황판 더미 거래처명** | "(주)부산화학" 등 하드코딩 — 실제 거래처가 아님 | 백엔드 sub_label 필드 추가 → 프론트 더미값 제거 |
| ~~I2~~ | ~~현황판 vs 채권추심 건수 불일치~~ | 현황판=`status=collecting` 레코드 7건, 채권추심=`distinct(client_id)` 거래처 5곳 | **설계 의도** (다른 관점 지표) |
| ~~I3~~ | ~~카드사용내역 월별 합계 0원~~ | 카드 거래 20건 전부 2025-01~2026-01-28, 2/3월 거래 0건 | **버그 아님** (데이터 없음이 원인) |
| ~~I4~~ | ~~발행어음 데이터 소스 불명확~~ | `expected_expenses`(예측)와 `bills`(실제)는 별도 테이블 | **설계 의도** (독립 데이터) |
| I5 | **매입 "당월" 라벨 부정확** | subtitle "당월 매입 실적" + Badge "당월"이나 실제 데이터는 연간 누적(`whereYear`) | 프론트엔드 라벨 수정 |
### 🟢 Minor → 수정 불필요 (최종 확인)
| # | 이슈 | 최종 판단 |
|---|------|----------|
| ~~M1~~ | 미수금 산출 기준 차이 | **설계 의도** — 다른 산출 방식 |
| ~~M2~~ | 가지급금 카드 금액 대조 불가 | **설계 의도** — 가지급금 전환 기준 vs 카드 사용 총액 |
### 최종 수정 필요 항목: 3건만
| 순위 | 이슈 | 영역 | 내용 |
|------|------|------|------|
| 1 | I1(B3) | 백엔드 | StatusBoardService/BadDebtService에 sub_label 필드 추가 |
| 2 | I1(F1) | 프론트 | 더미 거래처명 상수/함수 제거 → API sub_label 사용 |
| 3 | I5(F3) | 프론트 | 매입 섹션 "당월" → "연간"/"YTD" 라벨 수정 |
---
## Phase 4: 하단 섹션 추가 검증 (생산/출고/시공/근태) ✅ 완료
### 4-1. 생산 현황 (0공정) ✅ 정확
| 항목 | 대시보드 | 소스 페이지 (작업지시 관리) | 결과 |
|------|---------|--------------------------|------|
| 공정 수 | 0공정 | 전체 39건 (작업대기 39, 작업중 0, 완료 0) | ✅ |
| 본문 | "오늘 등록된 작업 지시가 없습니다" | 39건 모두 2월 날짜, 상태 "미배정" | ✅ |
> **검증**: 대시보드 API(`dashboard/production/summary`)는 `scheduled_date = today` 기준 조회. 39건의 작업지시는 모두 2026년 2월 날짜이므로 오늘(3월 9일) 예정 작업 없음 → 0공정 정확.
>
> **백엔드 코드**: `DashboardCeoService.php` — `work_orders` 테이블에서 `scheduled_date = today`, `is_active = true` 조건으로 공정별 집계. 출고 데이터도 동일 API에서 `shipment` 필드로 제공.
### 4-2. 출고 현황 (0건/0원) ✅ 정확
| 항목 | 대시보드 | 소스 페이지 (출고관리) | 결과 |
|------|---------|---------------------|------|
| 예상 출고 (7일 이내) | 0건/0원 | 당일 출고대기 0건 | ✅ |
| 예상 출고 (30일 이내) | 0건/0원 | 전체 8건 (모두 2025-12~2026-01) | ✅ |
> **검증**: 출고관리 페이지의 8건은 모두 2025년 12월~2026년 1월 날짜. 대시보드는 당월(3월) 기준 `status IN ('scheduled','ready')` 필터 → 해당 없음 → 0 정확.
>
> **미출고 6건**: `dashboard/unshipped/summary` API로 별도 조회. LOT번호(LOT-2024001~008), 현장명, 납기일 모두 소스 데이터와 일치. days_left가 모두 음수(D-64~D-69) → 납기 초과 상태.
### 4-3. 시공 현황 (0건) ✅ 비교 불가 (소스=Mock)
| 항목 | 대시보드 | 소스 페이지 (시공관리) | 결과 |
|------|---------|---------------------|------|
| 시공 진행 | 0건 | 시공진행 7건 | ⚠️ 차이 |
| 시공 완료 | 0건 | 시공완료 4건 | ⚠️ 차이 |
> **원인 분석**: 시공관리 페이지(`construction/management/actions.ts`)는 **Mock 데이터 사용 중** (line 22: `// 목업 데이터`, line 21: `TODO: 실제 API 연동 시 구현`). 화면에 표시되는 "시공진행 7건"은 하드코딩된 가짜 데이터.
>
> 대시보드는 실제 `contracts` 테이블 조회 (`DashboardCeoService.php:555-567`) — `contract_start_date`/`contract_end_date`가 당월(3월) 범위에 해당하는 계약 없음 → 0건 정확.
>
> **참고**: `contracts` 테이블에서 `end_date IS NULL`인 진행 중 계약 처리 — 현재 쿼리는 `contract_end_date >= $monthEnd` 조건에서 NULL이 제외됨. 실제 계약 데이터 투입 시 이 조건의 적정성 재검토 권장 (NULL end_date = 아직 진행 중).
### 4-4. 근태 현황 (0명) ✅ 설계 차이
| 항목 | 대시보드 | 소스 페이지 (근태관리) | 결과 |
|------|---------|---------------------|------|
| 출근 | 0명 | 정시 출근 0명 | ✅ |
| 지각 | 0명 | 지각 0명 | ✅ |
| 휴가 | 0명 | 휴가 0명 | ✅ |
| 결근 | 0명 | - | ✅ |
| 미출근 | (미표시) | **55명** | ⚠️ 관점 차이 |
> **검증**: 대시보드 API(`dashboard/attendance/summary`)는 `attendances` 테이블에서 `base_date = today` 레코드만 조회 (`DashboardCeoService.php:677-694`). 오늘 출근 기록이 없으므로 모든 카운트 0, employees 배열 비어있음.
>
> 근태관리 페이지는 **전체 사원 명부 기반** — 등록된 55명의 사원에 대해 출근 기록 유무를 확인하고, 기록 없으면 "미출근"으로 표시.
>
> **설계 차이**: 대시보드="출근 기록 기반"(기록 있는 것만 카운트), 관리 페이지="사원 명부 기반"(전체 사원 대비 상태 표시). 대시보드에서 "미출근" 정보를 보여줄지는 비즈니스 결정 사항.
>
> **참고**: 55명 전원 "E2E_TEST_사원"(테스트 데이터), 부서/직책 모두 미지정. 실 운영 시에는 출근 기록이 생성되므로 정상 동작 예상.
---
## 검수 완료 항목
| 항목 | 상태 |
|------|------|
| Phase 1: 전체 18개 카드 수치 기록 | ✅ 완료 |
| Phase 1: LazySection 5개 섹션 재확인 | ✅ 완료 (2차) |
| Phase 2: 복리후생비 모달/날짜필터 | ✅ 완료 |
| Phase 3: 소스 페이지 대조 (9개 항목) | ✅ 완료 |
| Phase 3: 복리후생비 데이터 변경 반영 | ✅ 완료 |
| Phase 3: 코드 분석 (transformer/fallback) | ✅ 완료 (2차) |
| Phase 4: 하단 섹션 추가 검증 (생산/출고/시공/근태) | ✅ 완료 (3차) |
| Phase 5: 데이터 변경 반영 테스트 | ⏸ 이슈 수정 후 |

View File

@@ -0,0 +1,432 @@
# CEO 대시보드 데이터 흐름 검증 보고서
> **작성일**: 2026-03-06
> **목적**: 대시보드 ↔ 개별 페이지 간 데이터 연동 완전성 검증
> **🔴 이 문서에 정리된 데이터 레이어는 "확정된 인프라"로 고정. 디자인 변경 시 UI만 교체할 것.**
---
## 🔒 변경 금지 영역 (데이터 인프라)
디자인 변경 시 아래 파일들은 **절대 수정하지 않음**:
| 레이어 | 파일 | 역할 |
|--------|------|------|
| **Hooks** | `src/hooks/useCEODashboard.ts` | 23개 Hook, API 호출 |
| **Transformers** | `src/lib/api/dashboard/transformers/*.ts` | API→Frontend 변환 |
| **Types (API)** | `src/lib/api/dashboard/types.ts` | API 응답 타입 |
| **Types (UI)** | `src/components/business/CEODashboard/types.ts` | UI 컴포넌트 타입 |
| **Modal Configs** | `src/components/business/CEODashboard/modalConfigs/*.ts` | 모달 설정 |
디자인 변경 시 수정 가능한 파일:
- `sections/*.tsx` (JSX/CSS만)
- `CEODashboard.tsx` (레이아웃만)
- `components.tsx` (공통 UI 컴포넌트)
- `SummaryNavBar.tsx` (네비게이션)
- `skeletons/*.ts` (로딩 UI)
---
## 📊 전체 20개 섹션 데이터 흐름 매핑
### 1. 상품권 → 가지급금 → 접대비 (핵심 연관관계)
```
상품권 관리 (/accounting/gift-certificate)
├─ 등록: status='holding' → cm3(상품권) 카운트 증가, 접대비 미반영
├─ 수정: status='used' + entertainmentExpense='applicable'
│ → Backend: syncGiftCertificateExpense() 자동 실행
│ → expense_accounts INSERT (account_type='entertainment')
│ → 접대비 섹션 반영됨
├─ 조건별 접대비 분류:
│ ├─ 일련번호 없음 → et_no_receipt (증빙미비) ✅
│ ├─ 금액 > 50만원 → et_high_amount (고액결제) ✅
│ └─ 주말/심야 사용 → et_weekend (주말/심야) ✅
└─ 삭제: expense_accounts도 함께 삭제
```
**검증 시나리오:**
| # | 작업 | 기대 결과 (카드관리) | 기대 결과 (접대비) |
|---|------|-------------------|------------------|
| 1 | 상품권 100만원 등록 (holding) | cm3 금액 +100만원 | 미반영 |
| 2 | status → used, 접대비=해당 | cm3 유지 | 접대비 총액 +100만원, 고액결제 +1건 |
| 3 | 일련번호 삭제 | cm3 미증빙 +1건 | 증빙미비 +1건 |
| 4 | status → holding 복귀 | cm3 유지 | 접대비에서 제거 |
| 5 | 상품권 삭제 | cm3 금액 -100만원 | 접대비에서 제거 |
---
### 2. 미수금 (ReceivableSection)
```
매출관리 (/accounting/sales) → Sale 생성 → receivable_balance 증가
미수금현황 (/accounting/receivables-status) → 입금처리/연체설정
API: GET /api/v1/receivables/summary
useReceivable() → transformReceivableResponse() → ReceivableSection
```
**데이터 소스 → 대시보드 매핑:**
| 소스 페이지 | 작업 | 대시보드 반영 |
|-----------|------|------------|
| 매출관리 | 매출 등록 | 누적미수금 증가 |
| 미수금현황 | 입금 처리 | 누적미수금 감소 |
| 어음관리 | 어음 발행 | 미수금 일부 이월 |
| 미수금현황 | 연체 설정 | 체크포인트 메시지 변경 |
---
### 3. 채권추심 (DebtCollectionSection)
```
악성채권관리 (/accounting/bad-debt-collection) → BadDebt CRUD
API: GET /api/v1/bad-debts/summary
useDebtCollection() → transformDebtCollectionResponse() → DebtCollectionSection
```
**상태 전환:**
| 상태 | 카드 | 설명 |
|------|------|------|
| collecting | 추심중 | 채권 추심 진행 |
| legalAction | 법적조치 | 법적 절차 진행 |
| recovered | 회수완료 | 채권 회수 완료 |
---
### 4. 매출현황 (SalesStatusSection)
```
매출관리 (/accounting/sales) → Sale CRUD
API: GET /api/v1/dashboard/sales/summary
useSalesStatus() → transformSalesStatusResponse() → SalesStatusSection
```
**대시보드 표시:** 누적매출, 달성률, 전년동기대비, 당월매출, 월별추이차트, 거래처별차트, 일별내역
---
### 5. 구매현황 (PurchaseStatusSection)
```
매입관리 (/accounting/purchases) → Purchase CRUD
API: GET /api/v1/dashboard/purchases/summary
usePurchaseStatus() → transformPurchaseStatusResponse() → PurchaseStatusSection
```
**결제 상태 매핑:**
| DB 상태 | 표시 | 조건 |
|--------|------|------|
| paid | 결제완료 | withdrawal_id 있음 |
| unpaid | 미결제 | withdrawal_id 없음 |
| partial | 부분결제 | 일부만 결제 |
---
### 6. 카드/가지급금 (CardManagementSection)
```
카드거래 + 가지급금(Loan) 데이터
API: GET /api/proxy/card-transactions/summary + /loans/dashboard + /loans/tax-simulation
useCardManagement() → transformCardManagementResponse() → CardManagementSection
```
**5개 카드:** cm1(카드), cm2(경조사), cm3(상품권), cm4(접대비), cm_total(합계)
---
### 7. 접대비 (EntertainmentSection)
```
expense_accounts 테이블 (상품권/카드 접대비 전환 시 자동 INSERT)
API: GET /api/v1/entertainment/summary
useEntertainment() → transformEntertainmentResponse() → EntertainmentSection
```
**4개 리스크 카드:**
| 카드 | 조건 |
|------|------|
| 주말/심야 | expense_date가 토/일/심야 |
| 기피업종 | merchant_biz_type MCC 매칭 |
| 고액결제 | amount > 500,000원 |
| 증빙미비 | receipt_no IS NULL |
---
### 8. 복리후생비 (WelfareSection)
```
지출 결재 승인 → 복리후생 관련 지출 집계
API: GET /api/v1/welfare/summary
useWelfare() → transformWelfareResponse() → WelfareSection
```
**4개 리스크 카드:** 비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과
---
### 9. 부가세 (VatSection)
```
매출/매입 거래 → 부가세 자동 계산
API: GET /api/v1/vat/summary
useVat() → transformVatResponse() → VatSection
```
**신고 기한 색상:** D-15+(녹색), D-1~15(주황), D-0(빨강), D-(음수)(진빨강경고)
---
### 10. 당월 예상 지출 (MonthlyExpenseSection)
```
구매발주 + 카드결제 + 어음 → 유형별 집계
API: GET /api/v1/expected-expenses/summary
useMonthlyExpense() → transformMonthlyExpenseResponse() → MonthlyExpenseSection
```
**4개 카드:** 구매금액, 카드결제, 어음/외상, 전체합계
---
### 11. 일일일보 (DailyReportSection)
```
배송완료(매출) + 입금기록 + 결재완료(지출) → 오늘 기준 집계
API: GET /api/v1/daily-report/summary
useDailyReport() → transformDailyReportResponse() → DailyReportSection
```
**4개 카드:** 당일매출액, 당일입금액, 당일지출액, 당일순현금
---
### 12. 현황판 (StatusBoardSection)
```
각 도메인 페이지 → 미처리 건수 집계
API: GET /api/v1/status-board/summary
useStatusBoard() → transformStatusBoardResponse() → StatusBoardSection
```
**항목:** 수주, 채권추심, 안전재고, 세금신고, 신규업체, 연차, 차량, 장비, 결재요청
---
### 13. 오늘의 이슈 (TodayIssueSection)
```
각 도메인 이벤트 발생 → TodayIssue 자동 생성
API: GET /api/v1/today-issues/summary
useTodayIssue() → transformTodayIssueResponse() → TodayIssueSection
```
**이슈 타입:** sales_order, bad_debt, safety_stock, expected_expense, vat_report, approval_request, new_vendor, deposit, withdrawal
---
### 14. 일정/캘린더 (CalendarSection)
```
일정관리 + 발주일정 + 시공일정 + 공휴일/세무일정(상수)
API: GET /api/v1/calendar/schedules
useCalendar() → transformCalendarResponse() → CalendarSection
```
**일정 타입:** schedule(파랑), order(초록), construction(보라), holiday(빨강), tax(주황)
---
### 15. 일일생산 (DailyProductionSection)
```
작업지시 상태변경 → 공정별 집계 (오늘만)
API: GET /api/v1/dashboard/production/summary
useDailyProduction() → transformDailyProductionResponse() → DailyProductionSection
```
**공정별 탭:** 각 공정(스크린 등)의 전체/대기/진행/완료/긴급 카운트 + 작업자 진행률
---
### 16. 출하현황 (DailyProduction 내 ShipmentSection)
```
shipments 테이블 → 당월 예상/실제 출고 집계
production/summary API 내 shipment 필드
DailyProductionSection 내 출하현황 카드
```
---
### 17. 미출하 (UnshippedSection)
```
출하관리 → shipments status='scheduled'|'ready'
API: GET /api/v1/dashboard/unshipped/summary
useUnshipped() → transformUnshippedResponse() → UnshippedSection
```
**납기 색상:** ≤3일(빨강), ≤7일(주황), 이상(회색)
---
### 18. 공사현황 (ConstructionSection)
```
계약관리 → contracts 당월 포함 건
API: GET /api/v1/dashboard/construction/summary
useConstruction() → transformConstructionResponse() → ConstructionSection
```
**진행률:** (경과일/총일수) × 100, 완료=100%, 미시작=0%
---
### 19. 일일근태 (DailyAttendanceSection)
```
출퇴근기록 + 휴가신청 → 오늘 기준 분류
API: GET /api/v1/dashboard/attendance/summary
useDailyAttendance() → transformDailyAttendanceResponse() → DailyAttendanceSection
```
**상태 분류:** checkin ≤ 기준=출근, checkin > 기준=지각, leave=휴가, 없음=결근
---
### 20. Enhanced 섹션 (EnhancedSections.tsx)
일별 매출/매입 상세 내역 — SalesStatus/PurchaseStatus API의 daily_items 활용
---
## ⚡ 공통 갱신 메커니즘
- **자동 갱신 없음**: 대시보드는 수동 refetch() 또는 페이지 새로고침 시에만 갱신
- **sam_stat 5분 캐시**: 백엔드 통계 테이블 캐싱 (일부 섹션)
- **대시보드 진입 시**: useCEODashboard()가 모든 섹션 병렬 로드 (Promise.all)
---
## 📋 화면 검수 시나리오 (2단계용)
### 시나리오 A: 상품권 → 가지급금 → 접대비
1. 상품권 100만원 등록 (holding) → 카드관리 cm3 확인
2. status=used, 접대비=해당으로 수정 → 접대비 고액결제 확인
3. 일련번호 제거 → 접대비 증빙미비 확인
4. 상태 복귀 → 접대비에서 제거 확인
### 시나리오 B: 매출 → 미수금
1. 매출 등록 → 매출현황 + 미수금 증가 확인
2. 입금 처리 → 미수금 감소 확인
### 시나리오 C: 작업지시 → 생산현황
1. 작업지시 등록 (오늘) → 생산현황 대기 +1 확인
2. 상태 → 진행중 → 진행 +1, 대기 -1 확인
3. 상태 → 완료 → 완료 +1, 진행 -1 확인
### 시나리오 D: 근태
1. 출근 기록 → 출근 인원 +1 확인
2. 휴가 신청 승인 → 휴가 +1 확인
### 시나리오 E: 구매 → 지출
1. 구매 등록 → 구매현황 + 당월예상지출 증가 확인
2. 결제 처리 → 구매현황 미결제→결제완료 변경 확인
### 시나리오 F: 일일일보
1. 배송 완료 → 당일매출액 증가 확인
2. 입금 기록 → 당일입금액 증가 확인
---
## ✅ 화면 검수 결과 (2026-03-06 실행)
### 시나리오 A: 상품권 → 가지급금 → 접대비 (CRUD 전체 사이클 검증)
| Step | 작업 | 가지급금 상품권 | 접대비 | 결과 |
|------|------|----------------|--------|------|
| 1 | 100만원 등록 (holding) | 0→100만 | 미반영 | ✅ PASS |
| 2 | status→사용, 접대비=해당 | 100만→0원 | 고액결제 +100만 1건 | ✅ PASS |
| 3 | 일련번호 삭제 | 0원 유지 | 증빙미비 10만1건→110만2건 | ✅ PASS |
| 4 | status→보유 복귀 | 0→100만 복귀 | 접대비에서 전부 제거 | ✅ PASS |
| 5 | 상품권 삭제 | 100만→0원 | 변화 없음 | ✅ PASS |
**검증 결론**: 상품권↔가지급금↔접대비 양방향 연동 완벽 작동
### 전체 20개 섹션 데이터 일관성 검증 (대시보드 vs 소스 페이지)
| # | 섹션 | NavBar 값 | 상세 섹션 값 | API 연동 | 결과 |
|---|------|----------|------------|---------|------|
| 1 | 오늘의 이슈 | 2건 | 신규거래처 2건 표시 | ✅ | ✅ PASS |
| 2 | 자금현황 | 0원 | 일일일보 0원, 미수금 9.4억, 미지급금 1.6억 | ✅ | ✅ PASS |
| 3 | 현황판 | 7항목 | 수주0, 채권추심7, 안전재고833, 연차0 | ✅ | ✅ PASS |
| 4 | 당월예상지출 | 1억 | 매입0, 카드0, 발행어음1억 | ✅ | ✅ PASS |
| 5 | 가지급금 | 1,150만 | 카드1,150만, 경조사0, 상품권0, 접대비0 | ✅ | ✅ PASS |
| 6 | 접대비 | 10만 | 주말심야0, 기피업종0, 고액결제0, 증빙미비10만1건 | ✅ | ✅ PASS |
| 7 | 복리후생비 | 0원 | 4개 리스크 카드 모두 0원 0건 | ✅ | ✅ PASS |
| 8 | 미수금 | 9.4억 | 누적9.4억, 당월-533만, 거래처69건, Top3 표시 | ✅ | ✅ PASS |
| 9 | 채권추심 | 1.2억 | 추심중4,782만, 법적조치4,463만, 회수2,058만 | ✅ | ✅ PASS |
| 10 | 부가세 | 0원 | 매출세액0, 매입세액0, 미발행0건 | ✅ | ✅ PASS |
| 11 | 캘린더 | 26일정 | 3월 캘린더 정상, 공휴일/일정/신규업체 표시 | ✅ | ✅ PASS |
| 12 | 매출현황 | 1억 | 누적1억343만, 당월715만, 달성률4%, 월별차트/거래처차트 | ✅ | ✅ PASS |
| 13 | 당월매출내역 | - | 10건, 합계220만, 거래처별 필터 | ✅ | ✅ PASS |
| 14 | 매입현황 | 165만 | 누적165만, 미결제165만, 월별차트/유형별차트 | ✅ | ✅ PASS |
| 15 | 당월매입내역 | - | 1건, 165만, 미결제 | ✅ | ✅ PASS |
| 16 | 생산현황 | 0공정 | "오늘 등록된 작업 지시가 없습니다" | ✅ | ✅ PASS |
| 17 | 출고현황 | 0건 | 7일 이내 0건, 30일 이내 0건 | ✅ | ✅ PASS |
| 18 | 미출고내역 | 6건 | 6건 목록, 포트번호/현장명/납기일/남은일 표시 | ✅ | ✅ PASS |
| 19 | 시공현황 | 0건 | 시공진행0, 시공완료0 | ✅ | ✅ PASS |
| 20 | 근태현황 | 0명 | 출근0, 휴가0, 지각0, 결근0 | ✅ | ✅ PASS |
### 매출관리 ↔ 대시보드 교차검증
| 소스 페이지 | 소스 값 | 대시보드 값 | 일치 |
|-----------|---------|-----------|------|
| 매출관리 > 당월 매출 | 7,150,000원 | 당월 매출 715만 | ✅ |
| 매출관리 > 총 매출 | 17,050,000원 | 누적 매출 1억 343만 | ✅ (누적=해당년도) |
| 미수금 > 자금현황 | 9억 4,145만 | 미수금 섹션 9억 4,145만 | ✅ |
### 최종 검수 결론
- **전체 20개 섹션**: API 연동 확인, 데이터 정상 표시 ✅
- **CRUD 검증 (시나리오A)**: 등록→수정→상태변경→삭제 전 사이클 완벽 ✅
- **교차 섹션 연동**: 상품권↔가지급금↔접대비 양방향 완벽 ✅
- **NavBar ↔ 섹션 일관성**: 모든 NavBar 요약값과 상세 섹션값 일치 ✅
- **소스 페이지 ↔ 대시보드 일관성**: 매출관리 등 소스 데이터와 일치 ✅
**🟢 CEO 대시보드 백엔드 연동 검수 완료. 데이터 인프라 확정.**

View File

@@ -0,0 +1,70 @@
# 출하/배차 API 연동 — 배차 다중행 + 차량관리 + 출고관리
> **작업일**: 2026-03-03 ~ 03-07
> **상태**: ✅ 완료
> **커밋**: 83a23701, 1d380578, 5ff5093d, f653960a, a4f99ae3, 03d129c3
---
## 개요
출하/배차 관련 3개 모듈의 API 연동 및 레이아웃 개선.
---
## 1. 배차정보 다중 행 API 연동
기존 단일 배차 → `vehicle_dispatches` 배열 지원.
- [x] `ShipmentApiData``vehicle_dispatches` 배열 필드 추가
- [x] `transformApiToDetail()` — vehicle_dispatches 배열 매핑
- [x] `transformCreateFormToApi()` — 폼 vehicleDispatches → API vehicle_dispatches 변환
- [x] `transformEditFormToApi()` — 수정 시 동일 변환
- [x] `transformApiToListItem()` — 첫 번째 배차의 arrival_datetime 목록에 표시
- [x] 레거시 단일 배차 필드 하위호환 유지
### 주요 파일
- `src/components/outbound/ShipmentManagement/actions.ts`
---
## 2. 배차차량관리 Mock→API 전환
- [x] `executePaginatedAction` + `buildApiUrl` 패턴 적용
- [x] `transformToListItem()` — snake_case → camelCase 목록 변환
- [x] `transformToDetail()` — snake_case → camelCase 상세 변환
- [x] 쿼리 파라미터: `search`, `status`, `start_date`, `end_date`, `page`, `per_page`
- [x] options/shipment 관계 데이터 중첩 API 응답에서 추출
### 주요 파일
- `src/components/outbound/VehicleDispatchManagement/actions.ts` (+207/-207)
---
## 3. 출고관리 목록 필드 매핑
- [x] 5개 필드 API 매핑 추가: `writer_name`, `writer_id`, `delivery_date`
- [x] `OrderInfoApiData` 타입으로 주문 연결 정보 처리
- [x] `transformApiToListItem()` 수신자/수신주소/수신처/작성자/출고일 반영
### 주요 파일
- `src/components/outbound/ShipmentManagement/actions.ts`
---
## 4. 배차 상세/수정 레이아웃
- [x] 기본정보 그리드: 1열 → 2×4열 레이아웃 개선
### 주요 파일
- `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx`
- `src/components/outbound/ShipmentManagement/ShipmentEdit.tsx`
---
## 5. 출하관리 캘린더
- [x] 기본 뷰: day → week-time 변경
### 주요 파일
- `src/components/outbound/ShipmentManagement/ShipmentList.tsx`

View File

@@ -0,0 +1,105 @@
# 생산지시 API 연동 + 작업자 화면 + 중간검사
> **작업일**: 2026-03-01 ~ 03-07
> **상태**: ✅ 완료
> **커밋**: fa7efb7b, 8b6da749, 4331b84a, 0166601b, 8bcabafd, 2a2a356f, b45c35a5, 0b81e9c1
---
## 개요
생산지시(ProductionOrders) 목록/상세 페이지를 Mock→API 전환하고,
작업자 화면의 중간검사 입력 모달과 자재투입 모달을 대폭 개선한 작업.
---
## 1. 생산지시 목록/상세 API 연동
- [x] Mock → API 전환 (`executePaginatedAction` + `buildApiUrl` 패턴)
- [x] 서버사이드 페이지네이션 + 동적 탭 카운트 (stats API)
- [x] WorkOrder 상태 배지 6단계: 미배정 → 배정 → 작업중 → 검사 → 완료 → 출하
- [x] BOM null 상태 처리
- [x] PO 번호 = 생산지시 번호 매핑 (별도 PO 번호 필드 불필요)
- [x] `clientSideFiltering: false` (서버사이드 처리)
### 주요 파일
- `src/components/production/ProductionOrders/actions.ts` — 서버 액션 (getProductionOrders, getProductionOrderStats, getProductionOrderDetail)
- `src/components/production/ProductionOrders/types.ts` — API/프론트엔드 타입 정의
- `src/app/[locale]/(protected)/production-orders/page.tsx` — 목록 뷰
- `src/app/[locale]/(protected)/production-orders/[id]/page.tsx` — 상세 뷰
---
## 2. 절곡 중간검사 입력 모달 (InspectionInputModal)
- [x] 7개 제품 항목 통합 폼
- [x] 제품 ID 자동 매칭 (3단계): 정규화 → 키워드 → 인덱스 fallback
- [x] cellValues 구조: `{bending_state, length, width, spacing}`
- [x] PO 단위 데이터 공유 모델: 동일 PO 내 모든 아이템이 inspection_data 공유
- [x] 데이터 로딩: bending 공정 아이템 중 inspection_data 보유 시 전체 적용
- [x] 데이터 저장: 중간검사 완료 시 모든 workItem에 동기화
### 제품 ID 매칭 전략 (bending/utils.ts)
```
1순위: 정규화 후 정확 매치 (대소문자/공백/특수문자 제거)
2순위: 키워드 포함 검색
3순위: 인덱스 기반 fallback
```
### 주요 파일
- `src/components/production/WorkerScreen/InspectionInputModal.tsx` (+396)
- `src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx` (신규, +118)
- `src/components/production/WorkOrders/documents/bending/utils.ts` (신규, +60)
---
## 3. 자재투입 모달 (MaterialInputModal)
- [x] 동일 자재 다중 BOM 그룹 LOT 독립 관리
- [x] `bomGroupKey = ${item_id}-${category}-${partType}` 그룹핑
- [x] 카테고리 정렬 순서:
1. 가이드레일
2. 하단마감재
3. 셔터박스
4. 연기차단재
- [x] FIFO 자동충전 시 그룹 간 물리적 LOT 가용량 추적
- [x] 번호 배지 (①②③) + partType 배지
- [x] `allGroupsFulfilled` 조건으로 입력 버튼 활성화 제어
- [x] 그룹별 독립 전송: `bom_group_key` + `replace` 모드
### 주요 파일
- `src/components/production/WorkerScreen/MaterialInputModal.tsx` (+356)
- `src/components/production/WorkerScreen/actions.ts`
---
## 4. 공정 단계 검사범위 설정 (InspectionScope) — 신규
- [x] 전수검사 / 샘플링 / 그룹 3가지 타입
- [x] 샘플링 시 샘플 수(n) 입력 지원
- [x] StepForm 컴포넌트에 UI 추가
- [x] options JSON으로 API 저장
### 타입 정의
```typescript
type InspectionScopeType = 'FULL' | 'SAMPLING' | 'GROUP';
interface InspectionScope {
type: InspectionScopeType;
sampleSize?: number; // SAMPLING 타입일 때만
}
```
### 주요 파일
- `src/components/process-management/StepForm.tsx`
- `src/components/process-management/actions.ts`
- `src/types/process.ts`
---
## 5. 기타 개선
- [x] 작업자 화면 제품명: productCode만 표시 (간소화)
- [x] 작업자 화면 하드코딩 도면 이미지 영역 제거
- [x] BOM 공정 분류 접이식 카드 UI
- [x] TemplateInspectionContent: products 배열 → cellValues 자동 매핑

View File

@@ -0,0 +1,124 @@
# 품질관리 Mock→API 전환 및 검사 모달/문서 개선
> **작업일**: 2026-03-05 ~ 03-07
> **상태**: ✅ 완료
> **커밋**: 50e4c72c, 563b240f, 899493a7, fe930b58, e7263fee, 4ea03922, 295585d8, c150d807, e75d8f9b
---
## 개요
품질관리(InspectionManagement) 전체 모듈을 Mock 데이터에서 실제 API로 전환하고,
검사 모달/문서 렌더링/수주선택 기능을 대폭 개선한 작업.
---
## 1. API 전환
- [x] `USE_MOCK_FALLBACK = false` 설정, Mock 데이터 제거
- [x] 엔드포인트 연동
- `GET /api/v1/quality/documents` — 검사 목록
- `GET /api/v1/quality/documents/{id}` — 검사 상세
- `POST /api/v1/quality/documents` — 검사 등록
- `PUT /api/v1/quality/documents/{id}` — 검사 수정
- `GET /api/v1/quality/performance-reports` — 실적신고 목록
- [x] snake_case → camelCase 변환 함수 구현
- [x] InspectionFormData 필드 추가: `clientId`, `inspectorId`, `receptionDate`
- [x] 실적신고 API 응답 snake_case → camelCase 변환
### 주요 파일
- `src/components/quality/InspectionManagement/actions.ts`
- `src/components/quality/PerformanceReportManagement/actions.ts`
---
## 2. 검사 모달 개선 (ProductInspectionInputModal)
- [x] 기본값 null(미선택) 상태로 변경
- [x] 일괄 합격/초기화 토글 버튼
- [x] 시공 치수 필드 (너비/높이) — ConstructionInfo 인터페이스
- [x] 변경사유 입력 필드
- [x] 사진 첨부 (최대 2장, base64 인코딩)
- [x] 이전/다음 개소 네비게이션 + 자동저장
- [x] 레거시 검사 데이터 통합 (합격/불합격/진행중/미완)
- [x] 사진 없는 항목 → "진행중" 상태 표시
- [x] Eye 아이콘 → "보기" 텍스트 배지 변경
- [x] 배지 사이즈 통일
### 주요 파일
- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx` (+428/-210)
---
## 3. 수주선택 모달 (OrderSelectModal)
- [x] 발주처(clientName) 컬럼 추가
- [x] 모델명 컬럼 추가
- [x] 동일 발주처 + 동일 모델 필터링 제약
- [x] 모달 너비 확장: `sm:max-w-2xl``sm:max-w-3xl`
- [x] 수주 선택 시 개소 자동 펼침
- [x] 필터 안내 텍스트 추가
### SearchableSelectionModal 공통 컴포넌트 확장
- [x] `isItemDisabled` 콜백 prop 추가
- [x] 비활성 항목 스타일링 (opacity 감소, cursor 변경)
- [x] 전체선택 시 비활성 항목 제외
- [x] 이미 선택된 항목은 비활성이라도 해제 가능
### 주요 파일
- `src/components/quality/InspectionManagement/OrderSelectModal.tsx`
- `src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx`
- `src/components/organisms/SearchableSelectionModal/types.ts`
---
## 4. 제품검사 성적서 (FqcDocumentContent) — 신규
8컬럼 동적 렌더링 테이블 구현.
| 컬럼 | 설명 |
|------|------|
| No | 순번 |
| 검사항목 | 카테고리 기반 rowSpan 병합 |
| 세부항목 | 개별 항목명 |
| 검사기준 | 스펙/기준값 |
| 검사방법 | method + frequency 복합 rowSpan 병합 |
| 검사주기 | (검사방법과 함께 병합) |
| 측정값 | measurement_type에 따라: checkbox→양호/불량, numeric→숫자입력, none→비활성 |
| 판정 | 적합/부적합/null |
- [x] `buildFieldRowSpan` — 단일 필드 병합 (카테고리)
- [x] `buildCompositeRowSpan` — 복합 필드 병합 (method+frequency)
- [x] FQC 모드 우선 + legacy fallback 패턴
- [x] `useImperativeHandle``getInspectionData()` 외부 접근
- [x] Lazy Snapshot 준비 (`contentWrapperRef`)
### 주요 파일
- `src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx` (신규, +483)
---
## 5. 제품검사 요청서 (FqcRequestDocumentContent) — 신규
양식 기반(template_id: 66) 동적 렌더링 구현.
- [x] 결재라인 섹션
- [x] 기본정보 섹션 (7개 필드, 2컬럼 배치)
- [x] 입력 섹션 4개: 현장, 자재유통사, 시공자, 감리
- [x] 사전통보 테이블 (group_name 기반 3단계 헤더)
- [x] 오픈사이즈 발주 / 시공 치수 그룹 병합
- [x] EAV 데이터 구조: `section_id`, `column_id`, `row_index`, `field_key`, `field_value`
- [x] EAV 문서 없을 때 legacy fallback 적용
### 주요 파일
- `src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx` (신규, +461)
- `src/components/quality/InspectionManagement/documents/InspectionRequestModal.tsx`
- `src/components/quality/InspectionManagement/fqcActions.ts`
---
## 6. 수주 연결 동기화
- [x] `order_ids` 배열 매핑 (다중 수주 지원)
- [x] 개소별 `inspectionData` 서버 저장
- [x] FQC 문서에서 수주 연결 정보 동기화

View File

@@ -17,7 +17,7 @@ export default function VendorsPage() {
useEffect(() => {
if (mode !== 'new') {
getClients({ size: 100 })
getClients({ size: 1000 })
.then(result => {
setData(result.data);
setTotal(result.total);

View File

@@ -229,7 +229,7 @@ export default function QualityInspectionPage() {
}, []);
return (
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-hidden">
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-auto">
{/* 헤더 (설정 버튼 포함) */}
<Header
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
@@ -283,9 +283,9 @@ export default function QualityInspectionPage() {
{activeDay === 1 ? (
// ===== 기준/매뉴얼 심사 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-[500px]">
{/* 좌측: 점검표 항목 */}
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
? 'lg:col-span-3'
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
@@ -303,7 +303,7 @@ export default function QualityInspectionPage() {
{/* 중앙: 기준 문서화 */}
{displaySettings.showDocumentSection && (
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
}`}>
<Day1DocumentSection
@@ -318,7 +318,7 @@ export default function QualityInspectionPage() {
{/* 우측: 문서 뷰어 */}
{displaySettings.showDocumentViewer && (
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
}`}>
<Day1DocumentViewer document={selectedStandardDoc} />
@@ -327,8 +327,8 @@ export default function QualityInspectionPage() {
</div>
) : (
// ===== 로트 추적 심사 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-[500px]">
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto">
<ReportList
reports={filteredReports}
selectedId={selectedReport?.id || null}
@@ -336,7 +336,7 @@ export default function QualityInspectionPage() {
/>
</div>
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
<RouteList
routes={currentRoutes}
selectedId={selectedRoute?.id || null}
@@ -346,7 +346,7 @@ export default function QualityInspectionPage() {
/>
</div>
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
<DocumentList
documents={currentDocuments}
routeCode={selectedRoute?.code || null}

View File

@@ -6,6 +6,7 @@
*/
import { useState, useCallback, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
@@ -137,12 +138,14 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
if (isNewMode) {
const result = await createBadDebt(formData);
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '등록에 실패했습니다.' };
} else {
const result = await updateBadDebt(recordId!, formData);
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '수정에 실패했습니다.' };
@@ -159,6 +162,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
try {
const result = await deleteBadDebt(String(id));
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '삭제에 실패했습니다.' };

View File

@@ -14,6 +14,7 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -176,6 +177,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
deleteItem: async (id: string) => {
const result = await deleteBadDebt(id);
if (result.success) {
invalidateDashboard('badDebt');
setData((prev) => prev.filter((item) => item.id !== id));
}
return { success: result.success, error: result.error };

View File

@@ -9,6 +9,7 @@ import { apiDataToFormData, transformFormDataToApi } from './types';
import type { BillApiData } from './types';
import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions';
import { useBillForm } from './hooks/useBillForm';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useBillConditions } from './hooks/useBillConditions';
import {
BasicInfoSection,
@@ -130,6 +131,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
if (isNewMode) {
const result = await createBillRaw(apiPayload);
if (result.success) {
invalidateDashboard('bill');
toast.success('등록되었습니다.');
router.push('/ko/accounting/bills');
return { success: false, error: '' };
@@ -137,6 +139,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
return result;
} else {
const result = await updateBillRaw(String(billId), apiPayload);
if (result.success) {
invalidateDashboard('bill');
}
return result;
}
} finally {

View File

@@ -22,8 +22,7 @@ import {
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
@@ -89,24 +88,6 @@ export function BillManagementClient({
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
const itemsPerPage = initialPagination.perPage;
// 삭제 다이얼로그
const deleteDialog = useDeleteDialog({
onDelete: async (id) => {
const result = await deleteBill(id);
if (result.success) {
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
await loadData(currentPage);
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return result;
},
entityName: '어음',
});
// 날짜 범위 상태
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
@@ -304,6 +285,7 @@ export function BillManagementClient({
}
if (successCount > 0) {
invalidateDashboard('bill');
toast.success(`${successCount}건이 저장되었습니다.`);
loadData(currentPage);
setSelectedItems(new Set());
@@ -334,6 +316,25 @@ export function BillManagementClient({
totalCount: pagination.total,
};
},
deleteItem: async (id: string) => {
const result = await deleteBill(id);
if (result.success) {
invalidateDashboard('bill');
await loadData(currentPage);
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return { success: result.success, error: result.error };
},
},
// 삭제 확인 메시지
deleteConfirmMessage: {
title: '어음 삭제',
description: '이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
},
// 테이블 컬럼
@@ -445,6 +446,7 @@ export function BillManagementClient({
isLoading,
router,
loadData,
currentPage,
handleSave,
renderTableRow,
renderMobileCard,
@@ -471,14 +473,6 @@ export function BillManagementClient({
}}
/>
<DeleteConfirmDialog
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="어음 삭제"
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={deleteDialog.isPending}
/>
</>
);
}

View File

@@ -158,7 +158,7 @@ export async function updateBillRaw(id: string, data: Record<string, unknown>):
// ===== 거래처 목록 조회 =====
export async function getClients(): Promise<ActionResult<{ id: number; name: string }[]>> {
return executeServerAction({
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];

View File

@@ -16,6 +16,7 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { formatNumber } from '@/lib/utils/amount';
import { getBills, deleteBill, updateBillStatus } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import { extractUniqueOptions } from '../shared';
import {
@@ -209,6 +210,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
}
if (successCount > 0) {
invalidateDashboard('bill');
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
await loadBills();
}
@@ -247,6 +249,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
deleteItem: async (id: string) => {
const result = await deleteBill(id);
if (result.success) {
invalidateDashboard('bill');
// 서버에서 재조회 (pagination 메타데이터 포함)
await loadBills();
}

View File

@@ -23,7 +23,8 @@ import {
SelectValue,
} from '@/components/ui/select';
import type { CardTransaction, JournalEntryItem } from './types';
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
import { DEDUCTION_OPTIONS } from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { saveJournalEntries } from './actions';
interface JournalEntryModalProps {
@@ -194,23 +195,16 @@ export function JournalEntryModal({ open, onOpenChange, transaction, onSuccess }
{/* 계정과목 + 공제 + 증빙/판매자상호 */}
<div className="grid grid-cols-3 gap-3">
{/* Select - FormField 예외 */}
<div>
<Label className="text-xs"></Label>
<Select
value={item.accountSubject || 'none'}
onValueChange={(v) => updateItem(index, 'accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="mt-1 h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-1">
<AccountSubjectSelect
value={item.accountSubject}
onValueChange={(v) => updateItem(index, 'accountSubject', v)}
placeholder="선택"
size="sm"
/>
</div>
</div>
{/* Select - FormField 예외 */}
<div>

View File

@@ -25,7 +25,8 @@ import {
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import type { ManualInputFormData } from './types';
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
import { DEDUCTION_OPTIONS } from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { getCardList, createCardTransaction } from './actions';
import { getTodayString } from '@/lib/utils/date';
@@ -254,20 +255,13 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
</div>
<div>
<Label className="text-sm font-medium"></Label>
<Select
value={formData.accountSubject || 'none'}
onValueChange={(v) => handleChange('accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-1">
<AccountSubjectSelect
value={formData.accountSubject}
onValueChange={(v) => handleChange('accountSubject', v)}
placeholder="선택"
/>
</div>
</div>
</div>

View File

@@ -42,6 +42,7 @@ import type { CardTransaction, InlineEditData, SortOption } from './types';
import {
SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS,
} from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import {
getCardTransactionList,
getCardTransactionSummary,
@@ -89,7 +90,7 @@ const tableColumns = [
{ key: 'deductionType', label: '공제', className: 'min-w-[95px]', sortable: false },
{ key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]' },
{ key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]' },
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[130px]', sortable: false },
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[160px]', sortable: false },
{ key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false },
{ key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right' },
{ key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false },
@@ -599,20 +600,13 @@ export function CardTransactionInquiry() {
</TableCell>
{/* 계정과목 (인라인 Select) */}
<TableCell onClick={(e) => e.stopPropagation()}>
<Select
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || 'none'}
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="h-7 text-xs min-w-[90px] w-auto">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
<AccountSubjectSelect
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || ''}
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v)}
placeholder="선택"
size="sm"
className="min-w-[90px] w-auto"
/>
</TableCell>
{/* 분개 버튼 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>

View File

@@ -16,6 +16,7 @@ import {
getBankAccounts,
} from './actions';
import { useDevFill, generateDepositData } from '@/components/dev';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== Props =====
interface DepositDetailClientV2Props {
@@ -81,14 +82,17 @@ export default function DepositDetailClientV2({
: await updateDeposit(depositId!, submitData as Partial<DepositRecord>);
if (result.success && mode === 'create') {
invalidateDashboard('deposit');
toast.success('등록되었습니다.');
router.push('/ko/accounting/deposits');
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
}
return result.success
? { success: true }
: { success: false, error: result.error };
if (result.success) {
invalidateDashboard('deposit');
return { success: true };
}
return { success: false, error: result.error };
},
[mode, depositId, router]
);
@@ -98,9 +102,11 @@ export default function DepositDetailClientV2({
if (!depositId) return { success: false, error: 'ID가 없습니다.' };
const result = await deleteDeposit(depositId);
return result.success
? { success: true }
: { success: false, error: result.error };
if (result.success) {
invalidateDashboard('deposit');
return { success: true };
}
return { success: false, error: result.error };
}, [depositId]);
// ===== 모드 변경 핸들러 =====

View File

@@ -73,6 +73,7 @@ import { deleteDeposit, updateDepositTypes, getDeposits } from './actions';
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import {
extractUniqueOptions,
@@ -225,6 +226,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
deleteItem: async (id: string) => {
const result = await deleteDeposit(id);
if (result.success) {
invalidateDashboard('deposit');
toast.success('입금 내역이 삭제되었습니다.');
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
await handleRefresh();

View File

@@ -184,7 +184,7 @@ export async function getClients(): Promise<{
success: boolean; data: { id: string; name: string }[]; error?: string;
}> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
type ClientApi = { id: number; name: string };
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];

View File

@@ -15,6 +15,7 @@ import { useState, useMemo, useCallback, useTransition, useEffect } from 'react'
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import {
Receipt,
Calendar as CalendarIcon,
@@ -88,8 +89,8 @@ import { CurrencyInput } from '@/components/ui/currency-input';
import {
TRANSACTION_TYPE_FILTER_OPTIONS,
PAYMENT_STATUS_FILTER_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { extractUniqueOptions } from '../shared';
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
@@ -247,6 +248,7 @@ export function ExpectedExpenseManagement({
// 수정
const result = await updateExpectedExpense(editingItem.id, formData);
if (result.success && result.data) {
invalidateDashboard('expectedExpense');
setData(prev => prev.map(item => item.id === editingItem.id ? result.data! : item));
toast.success('미지급비용이 수정되었습니다.');
setShowFormDialog(false);
@@ -258,6 +260,7 @@ export function ExpectedExpenseManagement({
// 등록
const result = await createExpectedExpense(formData);
if (result.success && result.data) {
invalidateDashboard('expectedExpense');
setData(prev => [result.data!, ...prev]);
toast.success('미지급비용이 등록되었습니다.');
setShowFormDialog(false);
@@ -278,6 +281,7 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await deleteExpectedExpenses(selectedIds);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
setSelectedItems(new Set());
toast.success(`${result.deletedCount || selectedIds.length}건이 삭제되었습니다.`);
@@ -492,6 +496,7 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await deleteExpectedExpense(deleteTargetId);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
@@ -522,6 +527,7 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await updateExpectedPaymentDate(selectedIds, newExpectedDate);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.map(item =>
selectedItems.has(item.id)
? { ...item, expectedPaymentDate: newExpectedDate }
@@ -1185,21 +1191,12 @@ export function ExpectedExpenseManagement({
<div className="space-y-2">
<Label></Label>
<Select
value={formData.accountSubject}
<AccountSubjectSelect
value={formData.accountSubject || ''}
onValueChange={(value) => setFormData(prev => ({ ...prev, accountSubject: value }))}
>
<SelectTrigger>
<SelectValue placeholder="계정과목 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
placeholder="계정과목 선택"
category="expense"
/>
</div>
</div>

View File

@@ -33,6 +33,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { AccountSubjectSelect } from '@/components/accounting/common';
import {
Table,
TableBody,
@@ -56,14 +57,12 @@ import {
getJournalDetail,
updateJournalDetail,
deleteJournalDetail,
getAccountSubjects,
getVendorList,
} from './actions';
import type {
GeneralJournalRecord,
JournalEntryRow,
JournalSide,
AccountSubject,
VendorOption,
} from './types';
import { JOURNAL_SIDE_OPTIONS, JOURNAL_DIVISION_LABELS } from './types';
@@ -109,7 +108,6 @@ export function JournalEditModal({
const [accountNumber, setAccountNumber] = useState('');
// 옵션 데이터
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
const [vendors, setVendors] = useState<VendorOption[]>([]);
// 데이터 로드
@@ -119,15 +117,11 @@ export function JournalEditModal({
const loadData = async () => {
setIsLoading(true);
try {
const [detailRes, subjectsRes, vendorsRes] = await Promise.all([
const [detailRes, vendorsRes] = await Promise.all([
getJournalDetail(record.id),
getAccountSubjects({ category: 'all' }),
getVendorList(),
]);
if (subjectsRes.success && subjectsRes.data) {
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
}
if (vendorsRes.success && vendorsRes.data) {
setVendors(vendorsRes.data);
}
@@ -361,24 +355,14 @@ export function JournalEditModal({
</div>
</TableCell>
<TableCell className="p-1">
<Select
value={row.accountSubjectId || 'none'}
<AccountSubjectSelect
value={row.accountSubjectId}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
handleRowChange(row.id, 'accountSubjectId', v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{accountSubjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
size="sm"
placeholder="선택"
/>
</TableCell>
<TableCell className="p-1">
<Select

View File

@@ -42,8 +42,9 @@ import {
TableRow,
TableFooter,
} from '@/components/ui/table';
import { createManualJournal, getAccountSubjects, getVendorList } from './actions';
import type { JournalEntryRow, JournalSide, AccountSubject, VendorOption } from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { createManualJournal, getVendorList } from './actions';
import type { JournalEntryRow, JournalSide, VendorOption } from './types';
import { JOURNAL_SIDE_OPTIONS } from './types';
import { getTodayString } from '@/lib/utils/date';
@@ -81,7 +82,6 @@ export function ManualJournalEntryModal({
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
// 옵션 데이터
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
const [vendors, setVendors] = useState<VendorOption[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
@@ -94,13 +94,7 @@ export function ManualJournalEntryModal({
setDescription('');
setRows([createEmptyRow()]);
Promise.all([
getAccountSubjects({ category: 'all' }),
getVendorList(),
]).then(([subjectsRes, vendorsRes]) => {
if (subjectsRes.success && subjectsRes.data) {
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
}
getVendorList().then((vendorsRes) => {
if (vendorsRes.success && vendorsRes.data) {
setVendors(vendorsRes.data);
}
@@ -272,24 +266,14 @@ export function ManualJournalEntryModal({
</div>
</TableCell>
<TableCell className="p-1">
<Select
value={row.accountSubjectId || 'none'}
<AccountSubjectSelect
value={row.accountSubjectId}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
handleRowChange(row.id, 'accountSubjectId', v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{accountSubjects.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
size="sm"
placeholder="선택"
/>
</TableCell>
<TableCell className="p-1">
<Select

View File

@@ -8,69 +8,14 @@ import type {
GeneralJournalApiData,
GeneralJournalSummary,
GeneralJournalSummaryApiData,
AccountSubject,
AccountSubjectApiData,
JournalEntryRow,
VendorOption,
} from './types';
import {
transformApiToFrontend,
transformSummaryApi,
transformAccountSubjectApi,
} from './types';
// ===== Mock 데이터 (개발용) =====
function generateMockJournalData(): GeneralJournalRecord[] {
const descriptions = ['사무용품 구매', '직원 급여', '임대료 지급', '매출 입금', '교통비'];
const journalDescs = ['복리후생비', '급여', '임차료', '매출', '여비교통비'];
const divisions: Array<'deposit' | 'withdrawal' | 'transfer'> = ['deposit', 'withdrawal', 'transfer'];
const sources: Array<'manual' | 'linked'> = ['manual', 'linked'];
return Array.from({ length: 10 }, (_, i) => {
const division = divisions[i % 3];
const depositAmount = division === 'deposit' ? 100000 * (i + 1) : 0;
const withdrawalAmount = division === 'withdrawal' ? 80000 * (i + 1) : 0;
return {
id: String(5000 + i),
date: '2025-12-12',
division,
amount: depositAmount || withdrawalAmount || 50000,
description: descriptions[i % 5],
journalDescription: journalDescs[i % 5],
depositAmount,
withdrawalAmount,
balance: 1000000 - (i * 50000),
debitAmount: [6000, 100000, 50000, 0, 30000][i % 5],
creditAmount: [0, 0, 50000, 100000, 0][i % 5],
source: sources[i % 4 === 0 ? 0 : 1],
};
});
}
function generateMockSummary(): GeneralJournalSummary {
return { totalCount: 10, depositCount: 4, depositAmount: 400000, withdrawalCount: 3, withdrawalAmount: 300000, journalCompleteCount: 7, journalIncompleteCount: 3 };
}
function generateMockAccountSubjects(): AccountSubject[] {
return [
{ id: '101', code: '1010', name: '현금', category: 'asset', isActive: true },
{ id: '102', code: '1020', name: '보통예금', category: 'asset', isActive: true },
{ id: '201', code: '2010', name: '미지급금', category: 'liability', isActive: true },
{ id: '401', code: '4010', name: '매출', category: 'revenue', isActive: true },
{ id: '501', code: '5010', name: '복리후생비', category: 'expense', isActive: true },
];
}
function generateMockVendors(): VendorOption[] {
return [
{ id: '1', name: '삼성전자' },
{ id: '2', name: '(주)한국물류' },
{ id: '3', name: 'LG전자' },
{ id: '4', name: '현대모비스' },
{ id: '5', name: '(주)대한상사' },
];
}
// ===== 전표 목록 조회 =====
export async function getJournalEntries(params: {
startDate?: string;
@@ -91,15 +36,6 @@ export async function getJournalEntries(params: {
errorMessage: '전표 목록 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || result.data.length === 0) {
const mockData = generateMockJournalData();
return {
success: true as const,
data: mockData,
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockData.length },
};
}
return result;
}
@@ -119,10 +55,6 @@ export async function getJournalSummary(params: {
errorMessage: '전표 요약 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || !result.data) {
return { success: true, data: generateMockSummary() };
}
return result;
}
@@ -151,67 +83,6 @@ export async function createManualJournal(data: {
});
}
// ===== 계정과목 목록 조회 =====
export async function getAccountSubjects(params?: {
search?: string;
category?: string;
}): Promise<ActionResult<AccountSubject[]>> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/account-subjects', {
search: params?.search || undefined,
category: params?.category && params.category !== 'all' ? params.category : undefined,
}),
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
errorMessage: '계정과목 목록 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || !result.data || result.data.length === 0) {
return { success: true, data: generateMockAccountSubjects() };
}
return result;
}
// ===== 계정과목 추가 =====
export async function createAccountSubject(data: {
code: string;
name: string;
category: string;
}): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects'),
method: 'POST',
body: {
code: data.code,
name: data.name,
category: data.category,
},
errorMessage: '계정과목 추가에 실패했습니다.',
});
}
// ===== 계정과목 상태 토글 =====
export async function updateAccountSubjectStatus(
id: string,
isActive: boolean
): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
method: 'PATCH',
body: { is_active: isActive },
errorMessage: '계정과목 상태 변경에 실패했습니다.',
});
}
// ===== 계정과목 삭제 =====
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
method: 'DELETE',
errorMessage: '계정과목 삭제에 실패했습니다.',
});
}
// ===== 분개 상세 조회 =====
type JournalDetailData = {
id: number;
@@ -241,26 +112,6 @@ export async function getJournalDetail(id: string): Promise<ActionResult<Journal
errorMessage: '분개 상세 조회에 실패했습니다.',
});
// API 실패 시 mock fallback (개발용)
if (!result.success || !result.data) {
return {
success: true,
data: {
id: Number(id),
date: '2025-12-12',
division: 'deposit',
amount: 100000,
description: '사무용품 구매',
bank_name: '신한은행',
account_number: '110-123-456789',
journal_memo: '',
rows: [
{ id: 1, side: 'debit', account_subject_id: 501, account_subject_name: '복리후생비', vendor_id: 1, vendor_name: '삼성전자', debit_amount: 100000, credit_amount: 0, memo: '' },
{ id: 2, side: 'credit', account_subject_id: 101, account_subject_name: '현금', vendor_id: null, vendor_name: '', debit_amount: 0, credit_amount: 100000, memo: '' },
],
},
};
}
return result;
}
@@ -308,9 +159,5 @@ export async function getVendorList(): Promise<ActionResult<VendorOption[]>> {
errorMessage: '거래처 목록 조회에 실패했습니다.',
});
// API 실패 또는 빈 응답 시 mock fallback (개발용)
if (!result.success || !result.data || result.data.length === 0) {
return { success: true, data: generateMockVendors() };
}
return result;
}

View File

@@ -28,7 +28,7 @@ import {
import { MobileCard } from '@/components/organisms/MobileCard';
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
import { getJournalEntries, getJournalSummary } from './actions';
import { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
import { AccountSubjectSettingModal } from '@/components/accounting/common';
import { ManualJournalEntryModal } from './ManualJournalEntryModal';
import { JournalEditModal } from './JournalEditModal';
import type { GeneralJournalRecord, GeneralJournalSummary, PeriodButtonValue } from './types';
@@ -38,6 +38,7 @@ import {
getPeriodDates,
} from './types';
import { formatNumber } from '@/lib/utils/amount';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== 테이블 컬럼 (기획서 기준 10개) =====
const tableColumns = [
@@ -151,12 +152,14 @@ export function GeneralJournalEntry() {
const handleManualEntrySuccess = useCallback(() => {
setShowManualEntry(false);
loadData();
invalidateDashboard('journalEntry');
}, [loadData]);
// ===== 분개 수정 완료 =====
const handleJournalEditSuccess = useCallback(() => {
setJournalEditTarget(null);
loadData();
invalidateDashboard('journalEntry');
}, [loadData]);
// ===== 합계 계산 =====

View File

@@ -34,30 +34,6 @@ export const PERIOD_BUTTONS = [
export type PeriodButtonValue = (typeof PERIOD_BUTTONS)[number]['value'];
// ===== 계정과목 분류 =====
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
{ value: 'asset', label: '자산' },
{ value: 'liability', label: '부채' },
{ value: 'capital', label: '자본' },
{ value: 'revenue', label: '수익' },
{ value: 'expense', label: '비용' },
];
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: 'all', label: '전체' },
...ACCOUNT_CATEGORY_OPTIONS,
];
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
asset: '자산',
liability: '부채',
capital: '자본',
revenue: '수익',
expense: '비용',
};
// ===== 분개 구분 (차변/대변) =====
export type JournalSide = 'debit' | 'credit';
@@ -121,25 +97,6 @@ export interface GeneralJournalSummaryApiData {
journal_incomplete_count?: number;
}
// ===== 계정과목 =====
export interface AccountSubject {
id: string;
code: string;
name: string;
category: AccountSubjectCategory;
isActive: boolean;
}
export interface AccountSubjectApiData {
id: number;
code: string;
name: string;
category: string;
is_active: boolean | number;
created_at: string;
updated_at: string;
}
// ===== 분개 행 =====
export interface JournalEntryRow {
id: string;
@@ -216,17 +173,6 @@ export function transformSummaryApi(apiData: GeneralJournalSummaryApiData): Gene
};
}
// ===== 계정과목 API → Frontend 변환 =====
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
return {
id: String(apiData.id),
code: apiData.code,
name: apiData.name,
category: apiData.category as AccountSubjectCategory,
isActive: Boolean(apiData.is_active),
};
}
// ===== 기간 버튼 → 날짜 변환 =====
export function getPeriodDates(period: PeriodButtonValue): { start: string; end: string } {
const today = new Date();

View File

@@ -13,6 +13,7 @@ import {
updateGiftCertificate,
deleteGiftCertificate,
} from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import {
PURCHASE_PURPOSE_OPTIONS,
ENTERTAINMENT_EXPENSE_OPTIONS,
@@ -80,6 +81,7 @@ export function GiftCertificateDetail({
: await updateGiftCertificate(id!, formData);
if (result.success) {
invalidateDashboard('giftCertificate');
toast.success(isNew ? '상품권이 등록되었습니다.' : '상품권이 수정되었습니다.');
router.push('/ko/accounting/gift-certificates');
} else {
@@ -96,6 +98,7 @@ export function GiftCertificateDetail({
try {
const result = await deleteGiftCertificate(id);
if (result.success) {
invalidateDashboard('giftCertificate');
toast.success('상품권이 삭제되었습니다.');
router.push('/ko/accounting/gift-certificates');
} else {
@@ -134,8 +137,8 @@ export function GiftCertificateDetail({
label="일련번호"
value={formData.serialNumber}
onChange={(v) => handleChange('serialNumber', v)}
placeholder="자동 생성"
disabled={!isNew}
placeholder="일련번호를 입력하세요"
disabled={!isEditable}
/>
<FormField
label="상품권명"

View File

@@ -126,6 +126,8 @@ export async function getGiftCertificateSummary(params?: {
holding_amount?: number;
used_count?: number;
used_amount?: number;
entertainment_count?: number;
entertainment_amount?: number;
}) => ({
totalCount: data.total_count ?? 0,
totalAmount: data.total_amount ?? 0,
@@ -133,8 +135,8 @@ export async function getGiftCertificateSummary(params?: {
holdingAmount: data.holding_amount ?? 0,
usedCount: data.used_count ?? 0,
usedAmount: data.used_amount ?? 0,
entertainmentCount: 0,
entertainmentAmount: 0,
entertainmentCount: data.entertainment_count ?? 0,
entertainmentAmount: data.entertainment_amount ?? 0,
}),
errorMessage: '상품권 요약 조회에 실패했습니다.',
});

View File

@@ -44,8 +44,10 @@ import type {
import {
getGiftCertificates,
getGiftCertificateSummary,
deleteGiftCertificate,
} from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import { applyFilters, enumFilter } from '@/lib/utils/search';
import { useDateRange } from '@/hooks';
@@ -123,7 +125,7 @@ export function GiftCertificateManagement() {
// ===== 핸들러 =====
const handleRowClick = useCallback((item: GiftCertificateRecord) => {
router.push(`/accounting/gift-certificates?mode=edit&id=${item.id}`);
router.push(`/accounting/gift-certificates?mode=view&id=${item.id}`);
}, [router]);
const handleCreate = useCallback(() => {
@@ -145,6 +147,14 @@ export function GiftCertificateManagement() {
data,
totalCount: data.length,
}),
deleteItem: async (id: string) => {
const result = await deleteGiftCertificate(id);
if (result.success) {
invalidateDashboard('giftCertificate');
await loadData();
}
return { success: result.success, error: result.error };
},
},
columns: tableColumns,
@@ -359,7 +369,7 @@ export function GiftCertificateManagement() {
);
},
}),
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate]
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate, loadData]
);
return (

View File

@@ -32,6 +32,7 @@ import {
deletePurchase,
} from './actions';
import { getClients } from '../VendorManagement/actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { toast } from 'sonner';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
@@ -260,6 +261,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
}
if (result?.success) {
invalidateDashboard('purchase');
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
return { success: true };
} else {
@@ -282,6 +284,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const result = await deletePurchase(purchaseId);
if (result.success) {
invalidateDashboard('purchase');
toast.success('매입이 삭제되었습니다.');
return { success: true };
} else {

View File

@@ -60,6 +60,7 @@ import {
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
} from './types';
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { formatNumber } from '@/lib/utils/amount';
// ===== 테이블 컬럼 정의 =====
@@ -253,6 +254,7 @@ export function PurchaseManagement() {
deleteItem: async (id: string) => {
const result = await deletePurchase(id);
if (result.success) {
invalidateDashboard('purchase');
setPurchaseData(prev => prev.filter(item => item.id !== id));
toast.success('매입이 삭제되었습니다.');
}

View File

@@ -34,6 +34,7 @@ import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTa
import { salesConfig } from './salesConfig';
import type { SalesRecord, SalesItem } from './types';
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { toast } from 'sonner';
import { getClients } from '../VendorManagement/actions';
@@ -173,6 +174,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
}
if (result?.success) {
invalidateDashboard('sales');
toast.success(isNewMode ? '매출이 등록되었습니다.' : '매출이 수정되었습니다.');
return { success: true };
} else {
@@ -195,6 +197,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const result = await deleteSale(salesId);
if (result.success) {
invalidateDashboard('sales');
toast.success('매출이 삭제되었습니다.');
return { success: true };
} else {

View File

@@ -303,7 +303,7 @@ export async function searchVendorsForTaxInvoice(
url: buildApiUrl('/api/v1/clients', {
q: query || undefined,
only_active: true,
size: 100,
size: 1000,
}),
transform: (data: { data: ClientApiData[] }) =>
data.data.map((item) => ({

View File

@@ -53,11 +53,11 @@ import {
updateJournalEntry,
deleteJournalEntry,
} from './actions';
import { AccountSubjectSelect } from '@/components/accounting/common';
import type { TaxInvoiceMgmtRecord, JournalEntryRow, JournalSide } from './types';
import {
TAB_OPTIONS,
JOURNAL_SIDE_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
interface JournalEntryModalProps {
@@ -288,25 +288,14 @@ export function JournalEntryModal({
</Select>
</TableCell>
<TableCell className="p-1">
<Select
<AccountSubjectSelect
value={row.accountSubject}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubject', v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.filter((o) => o.value).map(
(opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
placeholder="선택"
size="sm"
/>
</TableCell>
<TableCell className="p-1">
<Input

View File

@@ -18,6 +18,7 @@ import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { DatePicker } from '@/components/ui/date-picker';
import { FormField } from '@/components/molecules/FormField';
import { BusinessNumberInput } from '@/components/ui/business-number-input';
import {
Dialog,
DialogContent,
@@ -199,12 +200,14 @@ export function ManualEntryModal({
onChange={(value) => handleChange('vendorName', value)}
placeholder="공급자명"
/>
<FormField
label="사업자번호"
value={formData.vendorBusinessNumber}
onChange={(value) => handleChange('vendorBusinessNumber', value)}
placeholder="사업자번호"
/>
<div className="space-y-1.5">
<Label className="text-sm"></Label>
<BusinessNumberInput
value={formData.vendorBusinessNumber}
onChange={(value) => handleChange('vendorBusinessNumber', value)}
placeholder="000-00-00000"
/>
</div>
</div>
</div>

View File

@@ -8,8 +8,8 @@ import type {
TaxInvoiceMgmtApiData,
TaxInvoiceSummary,
TaxInvoiceSummaryApiData,
CardHistoryRecord,
CardHistoryApiData,
CardHistoryRecord,
ManualEntryFormData,
JournalEntryRow,
} from './types';
@@ -20,17 +20,6 @@ import {
transformSummaryApi,
} from './types';
// ===== 세금계산서 목록 Mock =====
// TODO: 실제 API 연동 시 Mock 제거
const MOCK_INVOICES: TaxInvoiceMgmtRecord[] = [
{ id: '1', division: 'sales', writeDate: '2026-01-15', issueDate: '2026-01-16', vendorName: '(주)삼성전자', vendorBusinessNumber: '124-81-00998', taxType: 'taxable', itemName: '전자부품', supplyAmount: 500000, taxAmount: 50000, totalAmount: 550000, receiptType: 'receipt', documentNumber: 'TI-001', status: 'journalized', source: 'hometax', memo: '' },
{ id: '2', division: 'sales', writeDate: '2026-01-20', issueDate: '2026-01-20', vendorName: '현대건설(주)', vendorBusinessNumber: '211-85-12345', taxType: 'taxable', itemName: '건축자재', supplyAmount: 1200000, taxAmount: 120000, totalAmount: 1320000, receiptType: 'claim', documentNumber: 'TI-002', status: 'pending', source: 'hometax', memo: '' },
{ id: '3', division: 'sales', writeDate: '2026-02-03', issueDate: null, vendorName: '(주)한국사무용품', vendorBusinessNumber: '107-86-55432', taxType: 'taxable', itemName: '사무용품', supplyAmount: 300000, taxAmount: 30000, totalAmount: 330000, receiptType: 'receipt', documentNumber: '', status: 'pending', source: 'manual', memo: '수기 입력' },
{ id: '4', division: 'purchase', writeDate: '2026-01-10', issueDate: '2026-01-11', vendorName: 'CJ대한통운', vendorBusinessNumber: '110-81-28388', taxType: 'taxable', itemName: '운송비', supplyAmount: 40000, taxAmount: 4000, totalAmount: 44000, receiptType: 'receipt', documentNumber: 'TI-003', status: 'journalized', source: 'hometax', memo: '' },
{ id: '5', division: 'purchase', writeDate: '2026-02-01', issueDate: '2026-02-01', vendorName: '스타벅스 역삼역점', vendorBusinessNumber: '201-86-99012', taxType: 'tax_free', itemName: '복리후생', supplyAmount: 14000, taxAmount: 1400, totalAmount: 15400, receiptType: 'receipt', documentNumber: 'TI-004', status: 'pending', source: 'hometax', memo: '' },
{ id: '6', division: 'purchase', writeDate: '2026-02-10', issueDate: null, vendorName: '(주)코스트코코리아', vendorBusinessNumber: '301-81-67890', taxType: 'taxable', itemName: '비품', supplyAmount: 200000, taxAmount: 20000, totalAmount: 220000, receiptType: 'claim', documentNumber: '', status: 'error', source: 'manual', memo: '수기 입력' },
];
// ===== 세금계산서 목록 조회 =====
export async function getTaxInvoices(params: {
division?: string;
@@ -41,45 +30,39 @@ export async function getTaxInvoices(params: {
page?: number;
perPage?: number;
}) {
// TODO: 실제 API 연동 시 아래 코드로 교체
// return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
// url: buildApiUrl('/api/v1/tax-invoices', { ... }),
// transform: transformApiToFrontend,
// errorMessage: '세금계산서 목록 조회에 실패했습니다.',
// });
const filtered = MOCK_INVOICES.filter((inv) => inv.division === (params.division || 'sales'));
return {
success: true as const,
data: filtered,
error: undefined as string | undefined,
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: filtered.length },
};
// frontend 'purchase' → backend 'purchases'
const direction = params.division === 'purchase' ? 'purchases' : params.division;
return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
url: buildApiUrl('/api/v1/tax-invoices', {
direction,
issue_date_from: params.startDate,
issue_date_to: params.endDate,
corp_name: params.vendorSearch || undefined,
page: params.page,
per_page: params.perPage,
}),
transform: transformApiToFrontend,
errorMessage: '세금계산서 목록 조회에 실패했습니다.',
});
}
// ===== 세금계산서 요약 조회 =====
export async function getTaxInvoiceSummary(_params: {
export async function getTaxInvoiceSummary(params: {
dateType?: string;
startDate?: string;
endDate?: string;
vendorSearch?: string;
}): Promise<ActionResult<TaxInvoiceSummary>> {
// TODO: 실제 API 연동 시 아래 코드로 교체
// return executeServerAction({ ... });
const sales = MOCK_INVOICES.filter((inv) => inv.division === 'sales');
const purchase = MOCK_INVOICES.filter((inv) => inv.division === 'purchase');
return {
success: true,
data: {
salesSupplyAmount: sales.reduce((s, i) => s + i.supplyAmount, 0),
salesTaxAmount: sales.reduce((s, i) => s + i.taxAmount, 0),
salesTotalAmount: sales.reduce((s, i) => s + i.totalAmount, 0),
salesCount: sales.length,
purchaseSupplyAmount: purchase.reduce((s, i) => s + i.supplyAmount, 0),
purchaseTaxAmount: purchase.reduce((s, i) => s + i.taxAmount, 0),
purchaseTotalAmount: purchase.reduce((s, i) => s + i.totalAmount, 0),
purchaseCount: purchase.length,
},
};
return executeServerAction<TaxInvoiceSummaryApiData, TaxInvoiceSummary>({
url: buildApiUrl('/api/v1/tax-invoices/summary', {
issue_date_from: params.startDate,
issue_date_to: params.endDate,
corp_name: params.vendorSearch || undefined,
}),
transform: transformSummaryApi,
errorMessage: '세금계산서 요약 조회에 실패했습니다.',
});
}
// ===== 세금계산서 수기 등록 =====
@@ -96,35 +79,24 @@ export async function createTaxInvoice(
}
// ===== 카드 내역 조회 =====
// TODO: 실제 API 연동 시 Mock 제거
const MOCK_CARD_HISTORY: CardHistoryRecord[] = [
{ id: '1', transactionDate: '2026-01-20', merchantName: '(주)삼성전자', amount: 550000, approvalNumber: 'AP-20260120-001', businessNumber: '124-81-00998' },
{ id: '2', transactionDate: '2026-01-25', merchantName: '현대오일뱅크 강남점', amount: 82500, approvalNumber: 'AP-20260125-003', businessNumber: '211-85-12345' },
{ id: '3', transactionDate: '2026-02-03', merchantName: '(주)한국사무용품', amount: 330000, approvalNumber: 'AP-20260203-007', businessNumber: '107-86-55432' },
{ id: '4', transactionDate: '2026-02-10', merchantName: 'CJ대한통운', amount: 44000, approvalNumber: 'AP-20260210-012', businessNumber: '110-81-28388' },
{ id: '5', transactionDate: '2026-02-14', merchantName: '스타벅스 역삼역점', amount: 15400, approvalNumber: 'AP-20260214-019', businessNumber: '201-86-99012' },
];
export async function getCardHistory(_params: {
export async function getCardHistory(params: {
startDate?: string;
endDate?: string;
search?: string;
page?: number;
perPage?: number;
}): Promise<ActionResult<CardHistoryRecord[]>> {
// TODO: 실제 API 연동 시 아래 코드로 교체
// return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
// url: buildApiUrl('/api/v1/card-transactions/history', {
// start_date: _params.startDate,
// end_date: _params.endDate,
// search: _params.search || undefined,
// page: _params.page,
// per_page: _params.perPage,
// }),
// transform: transformCardHistoryApi,
// errorMessage: '카드 내역 조회에 실패했습니다.',
// });
return { success: true, data: MOCK_CARD_HISTORY };
}) {
return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
url: buildApiUrl('/api/v1/card-transactions', {
start_date: params.startDate,
end_date: params.endDate,
search: params.search || undefined,
page: params.page,
per_page: params.perPage,
}),
transform: transformCardHistoryApi,
errorMessage: '카드 내역 조회에 실패했습니다.',
});
}
// ===== 분개 내역 조회 =====

View File

@@ -45,12 +45,14 @@ export const RECEIPT_TYPE_LABELS: Record<ReceiptType, string> = {
};
// ===== 세금계산서 상태 =====
export type InvoiceStatus = 'pending' | 'journalized' | 'error';
export type InvoiceStatus = 'draft' | 'issued' | 'sent' | 'cancelled' | 'failed';
export const INVOICE_STATUS_MAP: Record<InvoiceStatus, { label: string; color: string }> = {
pending: { label: '미분개', color: 'bg-yellow-100 text-yellow-700' },
journalized: { label: '분개완료', color: 'bg-green-100 text-green-700' },
error: { label: '오류', color: 'bg-red-100 text-red-700' },
draft: { label: '임시저장', color: 'bg-gray-100 text-gray-700' },
issued: { label: '발급완료', color: 'bg-blue-100 text-blue-700' },
sent: { label: '전송완료', color: 'bg-green-100 text-green-700' },
cancelled: { label: '취소', color: 'bg-red-100 text-red-700' },
failed: { label: '실패', color: 'bg-orange-100 text-orange-700' },
};
// ===== 소스 구분 (수기/홈택스) =====
@@ -87,24 +89,25 @@ export interface TaxInvoiceMgmtRecord {
memo: string;
}
// ===== API 응답 타입 (snake_case) =====
// ===== API 응답 타입 (백엔드 TaxInvoice 모델 기준) =====
export interface TaxInvoiceMgmtApiData {
id: number;
division: string;
write_date: string;
direction: string;
supplier_corp_num: string | null;
supplier_corp_name: string | null;
buyer_corp_num: string | null;
buyer_corp_name: string | null;
issue_date: string | null;
vendor_name: string;
vendor_business_number: string;
tax_type: string;
item_name: string;
supply_amount: string | number;
tax_amount: string | number;
total_amount: string | number;
receipt_type: string;
document_number: string;
status: string;
source: string;
memo: string | null;
invoice_type: string | null;
issue_type: string | null;
nts_confirm_num: string | null;
description: string | null;
barobill_invoice_id: string | null;
items: Array<{ name?: string; [key: string]: unknown }> | null;
created_at: string;
updated_at: string;
}
@@ -121,15 +124,20 @@ export interface TaxInvoiceSummary {
purchaseCount: number;
}
// 백엔드 summary API는 by_direction 중첩 구조로 응답
interface DirectionSummary {
count: number;
supply_amount: number;
tax_amount: number;
total_amount: number;
}
export interface TaxInvoiceSummaryApiData {
sales_supply_amount: number;
sales_tax_amount: number;
sales_total_amount: number;
sales_count: number;
purchase_supply_amount: number;
purchase_tax_amount: number;
purchase_total_amount: number;
purchase_count: number;
by_direction: {
sales: DirectionSummary;
purchases: DirectionSummary;
};
by_status: Record<string, number>;
}
// ===== 분개 항목 =====
@@ -165,11 +173,12 @@ export interface CardHistoryRecord {
export interface CardHistoryApiData {
id: number;
transaction_date: string;
used_at: string;
merchant_name: string;
amount: string | number;
approval_number: string;
business_number: string;
approval_number?: string;
business_number?: string;
description?: string | null;
}
// ===== 수기 입력 폼 데이터 =====
@@ -202,40 +211,66 @@ export const ACCOUNT_SUBJECT_OPTIONS = [
];
// ===== API → Frontend 변환 =====
const VALID_STATUSES: InvoiceStatus[] = ['draft', 'issued', 'sent', 'cancelled', 'failed'];
const INVOICE_TYPE_TO_TAX_TYPE: Record<string, TaxType> = {
tax_invoice: 'taxable',
modified: 'taxable',
invoice: 'tax_free',
};
const ISSUE_TYPE_TO_RECEIPT_TYPE: Record<string, ReceiptType> = {
receipt: 'receipt',
claim: 'claim',
};
export function transformApiToFrontend(apiData: TaxInvoiceMgmtApiData): TaxInvoiceMgmtRecord {
const isSales = apiData.direction === 'sales';
return {
id: String(apiData.id),
division: apiData.division as InvoiceTab,
writeDate: apiData.write_date,
division: isSales ? 'sales' : 'purchase',
writeDate: apiData.issue_date || apiData.created_at?.split('T')[0] || '',
issueDate: apiData.issue_date,
vendorName: apiData.vendor_name,
vendorBusinessNumber: apiData.vendor_business_number,
taxType: apiData.tax_type as TaxType,
itemName: apiData.item_name,
supplyAmount: Number(apiData.supply_amount),
taxAmount: Number(apiData.tax_amount),
totalAmount: Number(apiData.total_amount),
receiptType: apiData.receipt_type as ReceiptType,
documentNumber: apiData.document_number,
status: apiData.status as InvoiceStatus,
source: apiData.source as InvoiceSource,
memo: apiData.memo || '',
vendorName: isSales
? (apiData.buyer_corp_name || '')
: (apiData.supplier_corp_name || ''),
vendorBusinessNumber: isSales
? (apiData.buyer_corp_num || '')
: (apiData.supplier_corp_num || ''),
taxType: INVOICE_TYPE_TO_TAX_TYPE[apiData.invoice_type || ''] || 'taxable',
itemName: apiData.items?.[0]?.name || apiData.description || '',
supplyAmount: Number(apiData.supply_amount) || 0,
taxAmount: Number(apiData.tax_amount) || 0,
totalAmount: Number(apiData.total_amount) || 0,
receiptType: ISSUE_TYPE_TO_RECEIPT_TYPE[apiData.issue_type || ''] || 'receipt',
documentNumber: apiData.nts_confirm_num || '',
status: VALID_STATUSES.includes(apiData.status as InvoiceStatus)
? (apiData.status as InvoiceStatus)
: 'draft',
source: apiData.barobill_invoice_id ? 'hometax' : 'manual',
memo: apiData.description || '',
};
}
// ===== Frontend → API 변환 =====
export function transformFrontendToApi(data: ManualEntryFormData): Record<string, unknown> {
const isSales = data.division === 'sales';
return {
division: data.division,
write_date: data.writeDate,
vendor_name: data.vendorName,
vendor_business_number: data.vendorBusinessNumber,
direction: isSales ? 'sales' : 'purchases',
issue_type: 'normal',
issue_date: data.writeDate,
// 매출: 거래처=공급받는자(buyer), 매입: 거래처=공급자(supplier)
// DB 컬럼이 NOT NULL이므로 빈 문자열로 전송
supplier_corp_name: isSales ? '' : data.vendorName,
supplier_corp_num: isSales ? '' : data.vendorBusinessNumber,
buyer_corp_name: isSales ? data.vendorName : '',
buyer_corp_num: isSales ? data.vendorBusinessNumber : '',
supply_amount: data.supplyAmount,
tax_amount: data.taxAmount,
total_amount: data.totalAmount,
item_name: data.itemName,
tax_type: data.taxType,
memo: data.memo || null,
invoice_type: data.taxType === 'tax_free' ? 'invoice' : 'tax_invoice',
description: data.memo || null,
items: data.itemName ? [{ name: data.itemName, amount: data.supplyAmount }] : [],
};
}
@@ -243,24 +278,28 @@ export function transformFrontendToApi(data: ManualEntryFormData): Record<string
export function transformCardHistoryApi(apiData: CardHistoryApiData): CardHistoryRecord {
return {
id: String(apiData.id),
transactionDate: apiData.transaction_date,
transactionDate: apiData.used_at,
merchantName: apiData.merchant_name,
amount: Number(apiData.amount),
approvalNumber: apiData.approval_number,
businessNumber: apiData.business_number,
approvalNumber: apiData.approval_number || '',
businessNumber: apiData.business_number || '',
};
}
// ===== 요약 API → Frontend 변환 =====
const EMPTY_DIRECTION: DirectionSummary = { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 };
export function transformSummaryApi(apiData: TaxInvoiceSummaryApiData): TaxInvoiceSummary {
const sales = apiData.by_direction?.sales || EMPTY_DIRECTION;
const purchases = apiData.by_direction?.purchases || EMPTY_DIRECTION;
return {
salesSupplyAmount: apiData.sales_supply_amount,
salesTaxAmount: apiData.sales_tax_amount,
salesTotalAmount: apiData.sales_total_amount,
salesCount: apiData.sales_count,
purchaseSupplyAmount: apiData.purchase_supply_amount,
purchaseTaxAmount: apiData.purchase_tax_amount,
purchaseTotalAmount: apiData.purchase_total_amount,
purchaseCount: apiData.purchase_count,
salesSupplyAmount: sales.supply_amount,
salesTaxAmount: sales.tax_amount,
salesTotalAmount: sales.total_amount,
salesCount: sales.count,
purchaseSupplyAmount: purchases.supply_amount,
purchaseTaxAmount: purchases.tax_amount,
purchaseTotalAmount: purchases.total_amount,
purchaseCount: purchases.count,
};
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { formatNumber } from '@/lib/utils/amount';
import { Plus, Trash2, Upload } from 'lucide-react';
@@ -194,6 +195,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
return { success: false, error: result.message || '저장에 실패했습니다.' };
}
invalidateDashboard('client');
router.refresh();
return { success: true };
} catch (error) {
@@ -214,6 +216,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
return { success: false, error: result.message || '삭제에 실패했습니다.' };
}
invalidateDashboard('client');
router.refresh();
return { success: true };
} catch (error) {

View File

@@ -11,6 +11,7 @@
*/
import { useState, useMemo, useCallback } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import { formatNumber } from '@/lib/utils/amount';
@@ -129,6 +130,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
deleteItem: async (id: string) => {
const result = await deleteClient(id);
if (result.success) {
invalidateDashboard('client');
toast.success('거래처가 삭제되었습니다.');
}
return { success: result.success, error: result.error };

View File

@@ -16,6 +16,7 @@ import {
getBankAccounts,
} from './actions';
import { useDevFill, generateWithdrawalData } from '@/components/dev';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== Props =====
interface WithdrawalDetailClientV2Props {
@@ -82,6 +83,7 @@ export default function WithdrawalDetailClientV2({
: await updateWithdrawal(withdrawalId!, submitData as Partial<WithdrawalRecord>);
if (result.success) {
invalidateDashboard('withdrawal');
toast.success(mode === 'create' ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.');
router.push('/ko/accounting/withdrawals');
return { success: true };
@@ -99,6 +101,7 @@ export default function WithdrawalDetailClientV2({
const result = await deleteWithdrawal(withdrawalId);
if (result.success) {
invalidateDashboard('withdrawal');
toast.success('출금 내역이 삭제되었습니다.');
router.push('/ko/accounting/withdrawals');
return { success: true };

View File

@@ -72,9 +72,9 @@ import { deleteWithdrawal, updateWithdrawalTypes, getWithdrawals } from './actio
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import {
createDeleteItemHandler,
extractUniqueOptions,
createDateAmountSortFn,
computeMonthlyTotal,
@@ -237,7 +237,15 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
totalCount: initialData.length,
};
},
deleteItem: createDeleteItemHandler(deleteWithdrawal, setWithdrawalData, '출금 내역이 삭제되었습니다.'),
deleteItem: async (id: string) => {
const result = await deleteWithdrawal(id);
if (result.success) {
setWithdrawalData(prev => prev.filter(item => item.id !== id));
invalidateDashboard('withdrawal');
toast.success('출금 내역이 삭제되었습니다.');
}
return { success: result.success, error: result.error };
},
},
// 테이블 컬럼

View File

@@ -0,0 +1,215 @@
'use client';
/**
* 계정과목 Select 공용 컴포넌트
*
* DB 마스터에서 활성 계정과목(소분류, depth=3)을 로드하여 검색 가능한 Select로 표시.
* "[코드] 계정과목명" 형태로 표시. 코드/이름으로 검색 가능.
* Popover + Command 패턴 (SearchableSelect 기반).
* props로 category 제한 가능.
*/
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import { cn } from '@/components/ui/utils';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { getAccountSubjects } from './actions';
import type { AccountSubject, AccountSubjectCategory } from './types';
import { formatAccountLabel } from './types';
interface AccountSubjectSelectProps {
value: string;
onValueChange: (value: string) => void;
/** 특정 대분류만 표시 */
category?: AccountSubjectCategory;
/** 특정 중분류만 표시 */
subCategory?: string;
/** 특정 부문만 표시 */
departmentType?: string;
placeholder?: string;
disabled?: boolean;
className?: string;
/** 빈 값(전체) 옵션 표시 여부 */
showAllOption?: boolean;
allOptionLabel?: string;
/** 트리거 크기 */
size?: 'default' | 'sm';
/** value/onValueChange에 사용할 필드 (기본: code) */
valueField?: 'code' | 'id';
}
export function AccountSubjectSelect({
value,
onValueChange,
category,
subCategory,
departmentType,
placeholder = '계정과목 선택',
disabled = false,
className,
showAllOption = false,
allOptionLabel = '전체',
size = 'default',
valueField = 'code',
}: AccountSubjectSelectProps) {
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const triggerRef = useRef<HTMLButtonElement>(null);
const loadSubjects = useCallback(async () => {
setIsLoading(true);
try {
const result = await getAccountSubjects({
selectable: true,
isActive: true,
category: category || undefined,
subCategory: subCategory || undefined,
departmentType: departmentType || undefined,
});
if (result.success && result.data) {
setSubjects(result.data);
}
} catch {
// 조회 실패 시 빈 목록 유지
} finally {
setIsLoading(false);
}
}, [category, subCategory, departmentType]);
useEffect(() => {
loadSubjects();
}, [loadSubjects]);
// subject에서 value로 사용할 필드 추출
const getSubjectValue = useCallback(
(s: AccountSubject) => (valueField === 'id' ? s.id : s.code),
[valueField]
);
// 선택된 계정과목 찾기
const selectedSubject = useMemo(
() => subjects.find((s) => getSubjectValue(s) === value),
[subjects, value, getSubjectValue]
);
// 트리거에 표시할 텍스트
const displayLabel = useMemo(() => {
if (isLoading) return '로딩 중...';
if (value === 'all' && showAllOption) return allOptionLabel;
if (selectedSubject) return formatAccountLabel(selectedSubject);
return '';
}, [isLoading, value, showAllOption, allOptionLabel, selectedSubject]);
const handleSelect = (subjectValue: string) => {
onValueChange(subjectValue);
setOpen(false);
setSearchQuery('');
};
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen);
if (!isOpen) {
setSearchQuery('');
}
};
const triggerClassName = size === 'sm' ? 'h-8 text-sm' : 'h-9 text-sm';
return (
<Popover open={open} onOpenChange={handleOpenChange} modal>
<PopoverTrigger asChild>
<Button
ref={triggerRef}
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled || isLoading}
className={cn(
'w-full justify-between font-normal',
triggerClassName,
!displayLabel && 'text-muted-foreground',
className
)}
>
<span className="truncate">
{displayLabel || placeholder}
</span>
{isLoading ? (
<Loader2 className="ml-2 h-4 w-4 shrink-0 animate-spin" />
) : (
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[--radix-popover-trigger-width] min-w-[280px] p-0"
align="start"
>
<Command shouldFilter>
<CommandInput
placeholder="코드 또는 계정과목명 검색..."
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty> </CommandEmpty>
<CommandGroup>
{showAllOption && (
<CommandItem
value={allOptionLabel}
onSelect={() => handleSelect('all')}
className="cursor-pointer"
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === 'all' ? 'opacity-100' : 'opacity-0'
)}
/>
{allOptionLabel}
</CommandItem>
)}
{subjects.map((subject) => {
const subjectVal = getSubjectValue(subject);
return (
<CommandItem
key={subject.id}
value={`${subject.code} ${subject.name}`}
onSelect={() => handleSelect(subjectVal)}
className="cursor-pointer"
>
<Check
className={cn(
'mr-2 h-4 w-4',
value === subjectVal ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="text-muted-foreground mr-1.5 font-mono text-xs">
{subject.code}
</span>
<span>{subject.name}</span>
</CommandItem>
);
})}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,17 +1,18 @@
'use client';
/**
*
* ()
*
* - 추가: 코드, , Select,
* - 검색: 검색 Input, Select,
* - 테이블: 코드 | | | (/ ) | ()
* - 테이블: 코드 | | | | (/ ) | ()
* -
* - 버튼: 닫기
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { Plus, Trash2, Loader2 } from 'lucide-react';
import { Plus, Trash2, Loader2, Database } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
@@ -54,13 +55,16 @@ import {
createAccountSubject,
updateAccountSubjectStatus,
deleteAccountSubject,
seedDefaultAccountSubjects,
} from './actions';
import type { AccountSubject, AccountSubjectCategory } from './types';
import {
ACCOUNT_CATEGORY_OPTIONS,
ACCOUNT_CATEGORY_FILTER_OPTIONS,
ACCOUNT_CATEGORY_LABELS,
DEPARTMENT_TYPE_LABELS,
} from './types';
import type { DepartmentType } from './types';
interface AccountSubjectSettingModalProps {
open: boolean;
@@ -84,6 +88,7 @@ export function AccountSubjectSettingModal({
// 데이터
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSeeding, setIsSeeding] = useState(false);
// 삭제 확인
const [deleteTarget, setDeleteTarget] = useState<AccountSubject | null>(null);
@@ -195,10 +200,40 @@ export function AccountSubjectSettingModal({
}
}, [deleteTarget, loadSubjects]);
// 기본 계정과목표 생성
const handleSeedDefaults = useCallback(async () => {
setIsSeeding(true);
try {
const result = await seedDefaultAccountSubjects();
if (result.success) {
const count = result.data?.inserted_count ?? 0;
if (count > 0) {
toast.success(`기본 계정과목 ${count}건이 생성되었습니다.`);
} else {
toast.info('이미 모든 기본 계정과목이 등록되어 있습니다.');
}
loadSubjects();
} else {
toast.error(result.error || '기본 계정과목 생성에 실패했습니다.');
}
} catch {
toast.error('기본 계정과목 생성 중 오류가 발생했습니다.');
} finally {
setIsSeeding(false);
}
}, [loadSubjects]);
// depth에 따른 들여쓰기
const getIndentClass = (depth: number) => {
if (depth === 1) return 'font-bold';
if (depth === 2) return 'pl-4 font-medium';
return 'pl-8';
};
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[750px] max-h-[85vh] flex flex-col">
<DialogContent className="sm:max-w-[850px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> , , , </DialogDescription>
@@ -211,7 +246,7 @@ export function AccountSubjectSettingModal({
label="코드"
value={newCode}
onChange={setNewCode}
placeholder="코드"
placeholder="예: 10100"
/>
<FormField
label="계정과목명"
@@ -273,9 +308,23 @@ export function AccountSubjectSettingModal({
))}
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground ml-auto">
{filteredSubjects.length}
<span className="text-sm text-muted-foreground">
{filteredSubjects.length}
</span>
<Button
variant="outline"
size="sm"
className="h-9 ml-auto"
onClick={handleSeedDefaults}
disabled={isSeeding}
>
{isSeeding ? (
<Loader2 className="h-4 w-4 animate-spin mr-1" />
) : (
<Database className="h-4 w-4 mr-1" />
)}
</Button>
</div>
</div>
@@ -289,30 +338,36 @@ export function AccountSubjectSettingModal({
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-center w-[80px]"></TableHead>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
<TableHead className="text-center w-[70px]"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
<TableHead className="text-center w-[90px]"></TableHead>
<TableHead className="text-center w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredSubjects.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground h-[100px]">
.
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground h-[100px]">
. &quot; &quot; .
</TableCell>
</TableRow>
) : (
filteredSubjects.map((subject) => (
<TableRow key={subject.id}>
<TableCell className="text-sm font-mono">{subject.code}</TableCell>
<TableCell className="text-sm">{subject.name}</TableCell>
<TableCell className={`text-sm ${getIndentClass(subject.depth)}`}>
{subject.name}
</TableCell>
<TableCell className="text-center">
<Badge variant="outline" className="text-xs">
{ACCOUNT_CATEGORY_LABELS[subject.category]}
</Badge>
</TableCell>
<TableCell className="text-center text-xs text-muted-foreground">
{DEPARTMENT_TYPE_LABELS[subject.departmentType as DepartmentType] || '-'}
</TableCell>
<TableCell className="text-center">
<Button
variant={subject.isActive ? 'default' : 'outline'}

View File

@@ -0,0 +1,123 @@
'use server';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { AccountSubject, AccountSubjectApiData } from './types';
import { transformAccountSubjectApi } from './types';
// ===== 계정과목 목록 조회 =====
export async function getAccountSubjects(params?: {
search?: string;
category?: string;
subCategory?: string;
departmentType?: string;
depth?: number;
isActive?: boolean;
selectable?: boolean;
}): Promise<ActionResult<AccountSubject[]>> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects', {
search: params?.search || undefined,
category: params?.category && params.category !== 'all' ? params.category : undefined,
sub_category: params?.subCategory || undefined,
department_type: params?.departmentType || undefined,
depth: params?.depth,
is_active: params?.isActive,
selectable: params?.selectable,
}),
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
errorMessage: '계정과목 목록 조회에 실패했습니다.',
});
}
// ===== 계정과목 추가 =====
export async function createAccountSubject(data: {
code: string;
name: string;
category: string;
subCategory?: string;
parentCode?: string;
depth?: number;
departmentType?: string;
description?: string;
sortOrder?: number;
}): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects'),
method: 'POST',
body: {
code: data.code,
name: data.name,
category: data.category,
sub_category: data.subCategory || undefined,
parent_code: data.parentCode || undefined,
depth: data.depth ?? 3,
department_type: data.departmentType || 'common',
description: data.description || undefined,
sort_order: data.sortOrder,
},
errorMessage: '계정과목 추가에 실패했습니다.',
});
}
// ===== 계정과목 수정 =====
export async function updateAccountSubject(
id: string,
data: {
name?: string;
category?: string;
subCategory?: string;
parentCode?: string;
depth?: number;
departmentType?: string;
description?: string;
sortOrder?: number;
}
): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
method: 'PUT',
body: {
name: data.name,
category: data.category,
sub_category: data.subCategory,
parent_code: data.parentCode,
depth: data.depth,
department_type: data.departmentType,
description: data.description,
sort_order: data.sortOrder,
},
errorMessage: '계정과목 수정에 실패했습니다.',
});
}
// ===== 계정과목 상태 토글 =====
export async function updateAccountSubjectStatus(
id: string,
isActive: boolean
): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
method: 'PATCH',
body: { is_active: isActive },
errorMessage: '계정과목 상태 변경에 실패했습니다.',
});
}
// ===== 계정과목 삭제 =====
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
return executeServerAction({
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
method: 'DELETE',
errorMessage: '계정과목 삭제에 실패했습니다.',
});
}
// ===== 기본 계정과목표 일괄 생성 =====
export async function seedDefaultAccountSubjects(): Promise<ActionResult<{ inserted_count: number }>> {
return executeServerAction({
url: buildApiUrl('/api/v1/account-subjects/seed-defaults'),
method: 'POST',
errorMessage: '기본 계정과목 생성에 실패했습니다.',
});
}

View File

@@ -0,0 +1,18 @@
export { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
export { AccountSubjectSelect } from './AccountSubjectSelect';
export type {
AccountSubject,
AccountSubjectApiData,
AccountSubjectCategory,
AccountSubCategory,
DepartmentType,
} from './types';
export {
ACCOUNT_CATEGORY_OPTIONS,
ACCOUNT_CATEGORY_FILTER_OPTIONS,
ACCOUNT_CATEGORY_LABELS,
SUB_CATEGORY_LABELS,
DEPARTMENT_TYPE_LABELS,
transformAccountSubjectApi,
formatAccountLabel,
} from './types';

View File

@@ -0,0 +1,118 @@
/**
* 계정과목 공용 타입 및 상수
*
* 모든 회계 모듈에서 공유하는 계정과목 관련 타입/상수 정의.
* 기존 각 모듈별 ACCOUNT_SUBJECT_OPTIONS, AccountSubjectCategory 등을 대체.
*/
// ===== 계정과목 분류 (대분류) =====
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
{ value: 'asset', label: '자산' },
{ value: 'liability', label: '부채' },
{ value: 'capital', label: '자본' },
{ value: 'revenue', label: '수익' },
{ value: 'expense', label: '비용' },
];
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
{ value: 'all', label: '전체' },
...ACCOUNT_CATEGORY_OPTIONS,
];
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
asset: '자산',
liability: '부채',
capital: '자본',
revenue: '수익',
expense: '비용',
};
// ===== 중분류 =====
export type AccountSubCategory =
| 'current_asset'
| 'fixed_asset'
| 'current_liability'
| 'long_term_liability'
| 'capital'
| 'sales_revenue'
| 'other_revenue'
| 'cogs'
| 'selling_admin'
| 'other_expense';
export const SUB_CATEGORY_LABELS: Record<AccountSubCategory, string> = {
current_asset: '유동자산',
fixed_asset: '비유동자산',
current_liability: '유동부채',
long_term_liability: '비유동부채',
capital: '자본',
sales_revenue: '매출',
other_revenue: '영업외수익',
cogs: '매출원가',
selling_admin: '판매비와관리비',
other_expense: '영업외비용',
};
// ===== 부문 =====
export type DepartmentType = 'common' | 'manufacturing' | 'admin';
export const DEPARTMENT_TYPE_LABELS: Record<DepartmentType, string> = {
common: '공통',
manufacturing: '제조',
admin: '관리',
};
// ===== 계정과목 인터페이스 =====
export interface AccountSubject {
id: string;
code: string;
name: string;
category: AccountSubjectCategory;
subCategory: string | null;
parentCode: string | null;
depth: number;
departmentType: DepartmentType;
description: string | null;
sortOrder: number;
isActive: boolean;
}
export interface AccountSubjectApiData {
id: number;
code: string;
name: string;
category: string;
sub_category: string | null;
parent_code: string | null;
depth: number;
department_type: string;
description: string | null;
sort_order: number;
is_active: boolean | number;
created_at: string;
updated_at: string;
}
// ===== API → Frontend 변환 =====
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
return {
id: String(apiData.id),
code: apiData.code,
name: apiData.name,
category: apiData.category as AccountSubjectCategory,
subCategory: apiData.sub_category,
parentCode: apiData.parent_code,
depth: apiData.depth ?? 3,
departmentType: (apiData.department_type || 'common') as DepartmentType,
description: apiData.description,
sortOrder: apiData.sort_order ?? 0,
isActive: Boolean(apiData.is_active),
};
}
// ===== 표시용 포맷 =====
export function formatAccountLabel(subject: AccountSubject): string {
return `[${subject.code}] ${subject.name}`;
}

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useCallback, useEffect, useTransition, useRef, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter, useSearchParams } from 'next/navigation';
import { usePermission } from '@/hooks/usePermission';
import { format } from 'date-fns';
@@ -50,7 +51,7 @@ import { getClients } from '@/components/accounting/VendorManagement/actions';
// 초기 데이터 - SSR에서는 빈 문자열, 클라이언트에서 날짜 설정
const getInitialBasicInfo = (): BasicInfo => ({
drafter: '홍길동',
drafter: '', // 클라이언트에서 currentUser로 설정
draftDate: '', // 클라이언트에서 설정
documentNo: '',
documentType: 'proposal',
@@ -118,14 +119,22 @@ export function DocumentCreate() {
const today = format(new Date(), 'yyyy-MM-dd');
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
setBasicInfo(prev => ({ ...prev, draftDate: prev.draftDate || now }));
// localStorage 'user' 키에서 사용자 이름 가져오기 (로그인 시 저장됨)
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
const userName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name || '';
setBasicInfo(prev => ({
...prev,
drafter: prev.drafter || userName,
draftDate: prev.draftDate || now,
}));
setProposalData(prev => ({ ...prev, vendorPaymentDate: prev.vendorPaymentDate || today }));
setExpenseReportData(prev => ({
...prev,
requestDate: prev.requestDate || today,
paymentDate: prev.paymentDate || today,
}));
}, []);
}, [currentUser?.name]);
// 미리보기 모달 상태
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
@@ -172,6 +181,7 @@ export function DocumentCreate() {
setBasicInfo(prev => ({
...prev,
...mockData.basicInfo,
drafter: currentUserName || prev.drafter,
draftDate: prev.draftDate || mockData.basicInfo.draftDate,
documentType: (mockData.basicInfo.documentType || prev.documentType) as BasicInfo['documentType'],
}));
@@ -343,6 +353,7 @@ export function DocumentCreate() {
try {
const result = await deleteApproval(parseInt(documentId));
if (result.success) {
invalidateDashboard('approval');
toast.success('문서가 삭제되었습니다.');
router.back();
} else {
@@ -375,6 +386,7 @@ export function DocumentCreate() {
if (isEditMode && documentId) {
const result = await updateAndSubmitApproval(parseInt(documentId), formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('수정 및 상신 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
@@ -386,6 +398,7 @@ export function DocumentCreate() {
// 새 문서: 생성 후 상신
const result = await createAndSubmitApproval(formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('상신 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
@@ -411,6 +424,7 @@ export function DocumentCreate() {
if (isEditMode && documentId) {
const result = await updateApproval(parseInt(documentId), formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('저장 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
@@ -421,6 +435,7 @@ export function DocumentCreate() {
// 새 문서: 임시저장
const result = await createApproval(formData);
if (result.success) {
invalidateDashboard('approval');
toast.success('임시저장 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import {
@@ -175,6 +176,7 @@ export function DraftBox() {
try {
const result = await submitDrafts(ids);
if (result.success) {
invalidateDashboard('approval');
toast.success(`${ids.length}건의 문서를 상신했습니다.`);
loadData();
loadSummary();
@@ -200,6 +202,7 @@ export function DraftBox() {
try {
const result = await deleteDrafts(ids);
if (result.success) {
invalidateDashboard('approval');
toast.success(`${ids.length}건의 문서를 삭제했습니다.`);
loadData();
loadSummary();
@@ -222,6 +225,7 @@ export function DraftBox() {
try {
const result = await deleteDraft(id);
if (result.success) {
invalidateDashboard('approval');
toast.success('문서를 삭제했습니다.');
loadData();
loadSummary();
@@ -298,6 +302,7 @@ export function DraftBox() {
try {
const result = await submitDraft(selectedDocument.id);
if (result.success) {
invalidateDashboard('approval');
toast.success('문서를 상신했습니다.');
setIsModalOpen(false);
setSelectedDocument(null);

View File

@@ -4,6 +4,7 @@ import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import Image from "next/image";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@@ -268,7 +269,11 @@ export function LoginPage() {
/>
<span className="text-sm text-muted-foreground">{t('rememberMe')}</span>
</label>
<button type="button" className="text-sm text-primary hover:underline">
<button
type="button"
className="text-sm text-primary hover:underline"
onClick={() => toast.info('비밀번호 초기화는 시스템 관리자에게 요청해 주세요.')}
>
{t('forgotPassword')}
</button>
</div>

View File

@@ -41,6 +41,7 @@ import { useCardManagementModals } from '@/hooks/useCardManagementModals';
import { getCardManagementModalConfigWithData } from './modalConfigs';
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
import { toast } from 'sonner';
import { consumeStaleSections, DASHBOARD_INVALIDATE_EVENT, type DashboardSectionKey } from '@/lib/dashboard-invalidation';
export function CEODashboard() {
const router = useRouter();
@@ -70,6 +71,27 @@ export function CEODashboard() {
// Welfare API Hook (Phase 2)
const welfareData = useWelfare();
// 대시보드 targeted refetch: CUD 후 stale 섹션만 갱신
useEffect(() => {
const refetchSection = (key: string) => {
if (key === 'entertainment') entertainmentData.refetch();
else if (key === 'welfare') welfareData.refetch();
else apiData.refetchMap[key as DashboardSectionKey]?.();
};
const stale = consumeStaleSections();
if (stale.length > 0) {
for (const key of stale) refetchSection(key);
}
const handler = (e: Event) => {
const sections = (e as CustomEvent).detail?.sections as string[] | undefined;
if (sections) {
for (const key of sections) refetchSection(key);
}
};
window.addEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
return () => window.removeEventListener(DASHBOARD_INVALIDATE_EVENT, handler);
}, [apiData.refetchMap, entertainmentData.refetch, welfareData.refetch]);
// Card Management Modal API Hook (Phase 3)
const cardManagementModals = useCardManagementModals();

View File

@@ -47,12 +47,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
<CollapsibleDashboardCard
icon={<ShoppingCart className="h-5 w-5 text-white" />}
title="매입 현황"
subtitle="당월 매입 실적"
rightElement={
<Badge className="bg-amber-500 text-white border-none hover:opacity-90">
</Badge>
}
subtitle="매입 실적"
>
{/* 통계카드 3개 - 가로 배치 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
@@ -158,8 +153,8 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
{/* 당월 매입 내역 (별도 카드) */}
<CollapsibleDashboardCard
icon={<ShoppingCart className="h-5 w-5 text-white" />}
title="당월 매입 내역"
subtitle="당월 매입 거래 상세"
title="최근 매입 내역"
subtitle="매입 거래 상세"
bodyClassName="p-0"
>
<div className="p-3 bg-muted/50 border-b border-border space-y-2">

View File

@@ -725,13 +725,13 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
orders: true,
debtCollection: true,
safetyStock: true,
taxReport: false,
newVendor: false,
taxReport: true,
newVendor: true,
annualLeave: true,
vehicle: false,
equipment: false,
purchase: false,
approvalRequest: false,
approvalRequest: true,
fundStatus: true,
},
},
@@ -774,13 +774,13 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
orders: true,
debtCollection: true,
safetyStock: true,
taxReport: false,
newVendor: false,
taxReport: true,
newVendor: true,
annualLeave: true,
vehicle: false,
equipment: false,
purchase: false,
approvalRequest: false,
approvalRequest: true,
fundStatus: true,
},
},

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import { getTodayString, formatDate } from '@/lib/utils/date';
@@ -243,6 +244,7 @@ export default function ConstructionDetailClient({ id, mode }: ConstructionDetai
try {
const result = await updateConstructionManagementDetail(id, formData);
if (result.success) {
invalidateDashboard('construction');
toast.success('저장되었습니다.');
return { success: true };
} else {
@@ -265,6 +267,7 @@ export default function ConstructionDetailClient({ id, mode }: ConstructionDetai
try {
const result = await completeConstruction(id);
if (result.success) {
invalidateDashboard('construction');
toast.success('시공이 완료되었습니다.');
router.push('/ko/construction/project/construction-management');
} else {

View File

@@ -248,12 +248,14 @@ export function generatePurchaseApprovalData(options: GeneratePurchaseApprovalDa
const { vendors = SAMPLE_VENDORS, documentType = 'proposal' } = options;
const vendor = randomPick(vendors);
// 현재 사용자를 결재선에 추가 (기본값: 홍길동)
// 현재 사용자를 결재선에 추가 (기본값: 로그인 사용자 정보)
const userDataStr = typeof window !== 'undefined' ? localStorage.getItem('user') : null;
const userData = userDataStr ? JSON.parse(userDataStr) : null;
const currentUser: ApprovalPerson = options.currentUser || {
id: 'user-1',
department: '개발팀',
position: '사원',
name: '홍길동',
id: userData?.id || 'user-1',
department: userData?.department || '',
position: userData?.position || '',
name: userData?.name || '',
};
// 경리/회계/재무 직원 중 랜덤으로 1명 참조 추가

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import {
Clock,
@@ -310,6 +311,7 @@ export function AttendanceManagement() {
if (attendanceDialogMode === 'create') {
const result = await createAttendance(data);
if (result.success && result.data) {
invalidateDashboard('attendance');
setAttendanceRecords(prev => [result.data!, ...prev]);
} else {
console.error('Create failed:', result.error);
@@ -317,6 +319,7 @@ export function AttendanceManagement() {
} else if (selectedAttendance) {
const result = await updateAttendance(selectedAttendance.id, data);
if (result.success && result.data) {
invalidateDashboard('attendance');
setAttendanceRecords(prev =>
prev.map(r => r.id === selectedAttendance.id ? result.data! : r)
);

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { format } from 'date-fns';
import { useDateRange } from '@/hooks';
import {
@@ -312,6 +313,7 @@ export function VacationManagement() {
const ids = Array.from(selectedItems).map((id) => parseInt(id, 10));
const result = await approveLeavesMany(ids);
if (result.success) {
invalidateDashboard('leave');
await fetchLeaveRequests();
await fetchUsageData(); // 휴가 사용현황도 갱신
} else {
@@ -340,6 +342,7 @@ export function VacationManagement() {
const ids = Array.from(selectedItems).map((id) => parseInt(id, 10));
const result = await rejectLeavesMany(ids, '관리자에 의해 반려됨');
if (result.success) {
invalidateDashboard('leave');
await fetchLeaveRequests();
} else {
console.error('[VacationManagement] 반려 실패:', result.error);
@@ -750,6 +753,7 @@ export function VacationManagement() {
reason: data.reason,
});
if (result.success) {
invalidateDashboard('leave');
await fetchGrantData();
await fetchUsageData();
} else {
@@ -780,6 +784,7 @@ export function VacationManagement() {
days: data.vacationDays,
});
if (result.success) {
invalidateDashboard('leave');
await fetchLeaveRequests();
await fetchUsageData();
} else {

View File

@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Bookmark, MoreHorizontal } from 'lucide-react';
import { Pin, MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
@@ -51,7 +51,7 @@ function StarDropdown({
className={`p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${className ?? 'w-10 h-10'}`}
title="즐겨찾기"
>
<Bookmark className="h-4 w-4 fill-white" />
<Pin className="h-4 w-4 fill-white" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">

View File

@@ -1,4 +1,4 @@
import { Bookmark, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
import { Pin, ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle } from 'lucide-react';
import type { MenuItem } from '@/stores/menuStore';
import { useEffect, useRef, useCallback } from 'react';
import { useFavoritesStore, MAX_FAVORITES } from '@/stores/favoritesStore';
@@ -159,7 +159,7 @@ function MenuItemComponent({
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Bookmark className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
<Pin className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</div>
@@ -224,7 +224,7 @@ function MenuItemComponent({
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Bookmark className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
<Pin className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</div>
@@ -291,7 +291,7 @@ function MenuItemComponent({
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Bookmark className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
<Pin className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</div>

View File

@@ -27,7 +27,7 @@ interface SupplierItem {
interface SupplierSearchModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSelectSupplier: (supplier: { name: string; code?: string }) => void;
onSelectSupplier: (supplier: { id: number | string; name: string; code?: string }) => void;
}
// =============================================================================
@@ -115,7 +115,7 @@ export function SupplierSearchModal({
}, []);
const handleSelect = useCallback((supplier: SupplierItem) => {
onSelectSupplier({ name: supplier.name, code: supplier.clientCode });
onSelectSupplier({ id: supplier.id, name: supplier.name, code: supplier.clientCode });
}, [onSelectSupplier]);
return (

View File

@@ -9,6 +9,7 @@
*/
import { useState, useCallback, useEffect } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter, useSearchParams } from 'next/navigation';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -133,6 +134,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
const result = await updateStock(id, formData);
if (result.success) {
invalidateDashboard('stock');
toast.success('재고 정보가 저장되었습니다.');
// 상세 데이터 업데이트
setDetail((prev) =>

View File

@@ -12,6 +12,7 @@
*/
import { useState, useEffect, useCallback, useMemo } from "react";
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDaumPostcode } from "@/hooks/useDaumPostcode";
import { useClientList } from "@/hooks/useClientList";
import { Input } from "@/components/ui/input";
@@ -197,7 +198,7 @@ export function OrderRegistration({
// 컴포넌트 마운트 시 거래처 목록 불러오기
useEffect(() => {
fetchClients({ onlyActive: true, size: 100 });
fetchClients({ onlyActive: true, size: 1000 });
}, [fetchClients]);
// Daum 우편번호 서비스
@@ -504,6 +505,7 @@ export function OrderRegistration({
setIsSaving(true);
try {
await onSave(form);
invalidateDashboard('order');
return { success: true };
} catch (e) {
const errorMsg = e instanceof Error ? e.message : '저장 중 오류가 발생했습니다.';

View File

@@ -65,6 +65,7 @@ import {
type OrderStatus,
} from "@/components/orders";
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
import { invalidateDashboard } from "@/lib/dashboard-invalidation";
// 상태 뱃지 헬퍼
@@ -293,6 +294,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
try {
const result = await updateOrderStatus(order.id, "cancelled");
if (result.success) {
invalidateDashboard('sales');
setOrder({ ...order, status: "cancelled" });
toast.success("수주가 취소되었습니다.");
setIsCancelDialogOpen(false);
@@ -321,6 +323,7 @@ export function OrderSalesDetailView({ orderId }: OrderSalesDetailViewProps) {
try {
const result = await updateOrderStatus(order.id, "order_confirmed");
if (result.success && result.data) {
invalidateDashboard('sales');
setOrder(result.data);
toast.success("수주가 확정되었습니다.");
setIsConfirmDialogOpen(false);

View File

@@ -6,6 +6,7 @@
*/
import { useState, useCallback, useEffect } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { Plus, Trash2, ChevronDown, Search } from 'lucide-react';
import { Input } from '@/components/ui/input';
@@ -305,6 +306,7 @@ export function ShipmentEdit({ id }: ShipmentEditProps) {
if (!result.success) {
return { success: false, error: result.error || '출고 수정에 실패했습니다.' };
}
invalidateDashboard('shipment');
return { success: true };
} catch (err) {
if (isNextRedirectError(err)) throw err;

View File

@@ -68,7 +68,7 @@ function transformDetailApiToFrontend(data: ApiProductionOrderDetail): Productio
id: item.id,
itemCode: item.item_code,
itemName: item.item_name,
spec: item.spec || item.specification || '',
spec: item.spec || '',
unit: item.unit || '',
quantity: item.quantity ?? 0,
unitPrice: item.unit_price ?? 0,

View File

@@ -7,6 +7,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { FileText, X, Edit, Loader2, Plus, Search, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -291,6 +292,7 @@ export function WorkOrderCreate() {
if (!result.success) {
return { success: false, error: result.error || '작업지시 등록에 실패했습니다.' };
}
invalidateDashboard('production');
return { success: true };
} catch (error) {
if (isNextRedirectError(error)) throw error;

View File

@@ -7,6 +7,7 @@
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { FileText, Play, CheckCircle2, Loader2, Undo2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -272,6 +273,7 @@ export function WorkOrderDetail({ orderId }: WorkOrderDetailProps) {
try {
const result = await updateWorkOrderStatus(orderId, newStatus);
if (result.success && result.data) {
invalidateDashboard('production');
setOrder(result.data);
const statusLabels = {
waiting: '작업대기',

View File

@@ -8,6 +8,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { SquarePen, Trash2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
@@ -239,6 +240,7 @@ export function WorkOrderEdit({ orderId }: WorkOrderEditProps) {
});
if (result.success) {
invalidateDashboard('production');
toast.success('작업지시가 수정되었습니다.');
router.push(`/production/work-orders/${orderId}?mode=view`);
return { success: true };

View File

@@ -48,6 +48,9 @@ const OrderSelectModal = dynamic(
const ProductInspectionInputModal = dynamic(
() => import('./ProductInspectionInputModal').then(mod => ({ default: mod.ProductInspectionInputModal })),
);
const SupplierSearchModal = dynamic(
() => import('@/components/material/ReceivingManagement/SupplierSearchModal').then(mod => ({ default: mod.SupplierSearchModal })),
);
import type { InspectionFormData, OrderSettingItem, OrderSelectItem, OrderGroup, ProductInspectionData } from './types';
import {
emptyConstructionSite,
@@ -77,6 +80,7 @@ export function InspectionCreate() {
const [isSubmitting, setIsSubmitting] = useState(false);
const [orderModalOpen, setOrderModalOpen] = useState(false);
const [clientModalOpen, setClientModalOpen] = useState(false);
// 제품검사 입력 모달
const [inspectionInputOpen, setInspectionInputOpen] = useState(false);
@@ -123,10 +127,15 @@ export function InspectionCreate() {
changeReason: '',
}]
);
setFormData((prev) => ({
...prev,
orderItems: [...prev.orderItems, ...newOrderItems],
}));
setFormData((prev) => {
const updated = { ...prev, orderItems: [...prev.orderItems, ...newOrderItems] };
// 수주처 미선택 상태에서 수주 선택 시 → 수주처 자동 채움
if (!prev.clientId && items.length > 0 && items[0].clientId) {
updated.clientId = items[0].clientId ?? undefined;
updated.client = items[0].clientName || '';
}
return updated;
});
}, []);
// ===== 수주 항목 삭제 =====
@@ -238,9 +247,9 @@ export function InspectionCreate() {
toast.error('현장명은 필수 입력 항목입니다.');
return { success: false, error: '현장명을 입력해주세요.' };
}
if (!formData.client.trim()) {
toast.error('수주처는 필수 입력 항목입니다.');
return { success: false, error: '수주처를 입력해주세요.' };
if (!formData.clientId) {
toast.error('수주처는 필수 선택 항목입니다.');
return { success: false, error: '수주처를 선택해주세요.' };
}
setIsSubmitting(true);
@@ -400,11 +409,29 @@ export function InspectionCreate() {
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input
value={formData.client}
onChange={(e) => updateField('client', e.target.value)}
placeholder="수주처 입력"
/>
<div className="flex gap-2">
<Input
value={formData.client}
readOnly
placeholder="거래처를 선택하세요"
className="cursor-pointer bg-muted/30"
onClick={() => setClientModalOpen(true)}
/>
{formData.clientId && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
updateField('client', '');
updateField('clientId', undefined);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
)}
</div>
</div>
<div className="space-y-2">
<Label></Label>
@@ -691,16 +718,28 @@ export function InspectionCreate() {
[formData.orderItems]
);
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
// 수주 선택 필터: 기본정보 수주처 또는 이미 선택된 수주 기준
const orderFilter = useMemo(() => {
if (formData.orderItems.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
const first = formData.orderItems[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}, [formData.orderItems]);
// 기본정보에서 수주처가 선택된 경우 → 해당 거래처 필터
if (formData.clientId) {
const firstItem = formData.orderItems[0];
return {
clientId: formData.clientId,
itemId: firstItem?.itemId ?? undefined,
label: [formData.client, firstItem?.itemName].filter(Boolean).join(' / ') || undefined,
};
}
// 수주가 선택된 경우 → 첫 수주 기준 필터
if (formData.orderItems.length > 0) {
const first = formData.orderItems[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}
return { clientId: undefined, itemId: undefined, label: undefined };
}, [formData.clientId, formData.client, formData.orderItems]);
return (
<>
@@ -725,6 +764,17 @@ export function InspectionCreate() {
filterLabel={orderFilter.label}
/>
{/* 거래처(수주처) 검색 모달 */}
<SupplierSearchModal
open={clientModalOpen}
onOpenChange={setClientModalOpen}
onSelectSupplier={(supplier) => {
updateField('clientId', Number(supplier.id));
updateField('client', supplier.name);
setClientModalOpen(false);
}}
/>
{/* 제품검사 입력 모달 */}
<ProductInspectionInputModal
open={inspectionInputOpen}

View File

@@ -82,6 +82,9 @@ const ProductInspectionInputModal = dynamic(
const OrderSelectModal = dynamic(
() => import('./OrderSelectModal').then(mod => ({ default: mod.OrderSelectModal })),
);
const SupplierSearchModal = dynamic(
() => import('@/components/material/ReceivingManagement/SupplierSearchModal').then(mod => ({ default: mod.SupplierSearchModal })),
);
import type {
ProductInspection,
InspectionFormData,
@@ -137,6 +140,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
// 수주 선택 모달
const [orderModalOpen, setOrderModalOpen] = useState(false);
const [clientModalOpen, setClientModalOpen] = useState(false);
// 문서 모달
const [requestDocOpen, setRequestDocOpen] = useState(false);
@@ -213,6 +217,7 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
qualityDocNumber: result.data.qualityDocNumber,
siteName: result.data.siteName,
client: result.data.client,
clientId: result.data.clientId,
manager: result.data.manager,
managerContact: result.data.managerContact,
constructionSite: { ...result.data.constructionSite },
@@ -374,10 +379,15 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
changeReason: '',
}]
);
setFormData((prev) => ({
...prev,
orderItems: [...prev.orderItems, ...newOrderItems],
}));
setFormData((prev) => {
const updated = { ...prev, orderItems: [...prev.orderItems, ...newOrderItems] };
// 수주처 미선택 상태에서 수주 선택 시 → 수주처 자동 채움
if (!prev.clientId && items.length > 0 && items[0].clientId) {
updated.clientId = items[0].clientId ?? undefined;
updated.client = items[0].clientName || '';
}
return updated;
});
}, []);
const handleRemoveOrderItem = useCallback((itemId: string) => {
@@ -393,17 +403,29 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
[formData.orderItems]
);
// 이미 선택된 수주가 있으면 같은 거래처+모델만 필터
// 수주 선택 필터: 기본정보 수주처 또는 이미 선택된 수주 기준
const orderFilter = useMemo(() => {
const items = isEditMode ? formData.orderItems : (inspection?.orderItems || []);
if (items.length === 0) return { clientId: undefined, itemId: undefined, label: undefined };
const first = items[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}, [isEditMode, formData.orderItems, inspection?.orderItems]);
// 기본정보에서 수주처가 선택된 경우 → 해당 거래처 필터
if (formData.clientId) {
const firstItem = items[0];
return {
clientId: formData.clientId,
itemId: firstItem?.itemId ?? undefined,
label: [formData.client, firstItem?.itemName].filter(Boolean).join(' / ') || undefined,
};
}
// 수주가 선택된 경우 → 첫 수주 기준 필터
if (items.length > 0) {
const first = items[0];
return {
clientId: first.clientId ?? undefined,
itemId: first.itemId ?? undefined,
label: [first.clientName, first.itemName].filter(Boolean).join(' / ') || undefined,
};
}
return { clientId: undefined, itemId: undefined, label: undefined };
}, [isEditMode, formData.clientId, formData.client, formData.orderItems, inspection?.orderItems]);
// ===== 수주 설정 요약 =====
const orderSummary = useMemo(() => {
@@ -976,10 +998,28 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={formData.client}
onChange={(e) => updateField('client', e.target.value)}
/>
<div className="flex gap-2">
<Input
value={formData.client}
readOnly
placeholder="거래처를 선택하세요"
className="cursor-pointer bg-muted/30"
onClick={() => setClientModalOpen(true)}
/>
{formData.clientId && (
<Button
variant="ghost"
size="icon"
className="h-9 w-9 shrink-0"
onClick={() => {
updateField('client', '');
updateField('clientId', undefined);
}}
>
<Trash2 className="w-4 h-4 text-muted-foreground" />
</Button>
)}
</div>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground"></Label>
@@ -1334,6 +1374,17 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
filterLabel={orderFilter.label}
/>
{/* 거래처(수주처) 검색 모달 */}
<SupplierSearchModal
open={clientModalOpen}
onOpenChange={setClientModalOpen}
onSelectSupplier={(supplier) => {
updateField('clientId', Number(supplier.id));
updateField('client', supplier.name);
setClientModalOpen(false);
}}
/>
{/* 제품검사요청서 모달 */}
<InspectionRequestModal
open={requestDocOpen}

View File

@@ -19,6 +19,7 @@ import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type {
ProductInspection,
ProductInspectionData,
InspectionStats,
InspectionStatus,
InspectionCalendarItem,
@@ -42,6 +43,7 @@ interface ProductInspectionApi {
quality_doc_number: string;
site_name: string;
client: string;
client_id?: number | null;
location_count: number;
required_info: string;
inspection_period: string;
@@ -100,6 +102,8 @@ interface ProductInspectionApi {
construction_width: number;
construction_height: number;
change_reason: string;
document_id?: number | null;
inspection_data?: Record<string, unknown>;
}>;
request_document_id: number | null;
created_at: string;
@@ -193,6 +197,7 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
qualityDocNumber: api.quality_doc_number,
siteName: api.site_name,
client: api.client,
clientId: api.client_id ?? undefined,
locationCount: api.location_count,
requiredInfo: api.required_info,
inspectionPeriod: api.inspection_period,
@@ -252,7 +257,7 @@ function transformApiToFrontend(api: ProductInspectionApi): ProductInspection {
constructionHeight: item.construction_height,
changeReason: item.change_reason,
documentId: item.document_id ?? null,
inspectionData: item.inspection_data || undefined,
inspectionData: item.inspection_data ? item.inspection_data as unknown as ProductInspectionData : undefined,
})),
requestDocumentId: api.request_document_id ?? null,
};
@@ -589,13 +594,16 @@ export async function updateInspection(
});
// 개소별 데이터 (시공규격, 변경사유, 검사데이터)
apiData.locations = data.orderItems.map((item) => ({
id: Number(item.id),
post_width: item.constructionWidth || null,
post_height: item.constructionHeight || null,
change_reason: item.changeReason || null,
inspection_data: item.inspectionData || null,
}));
// 새로 추가된 항목(id가 "orderId-nodeId" 형태)은 order_ids 동기화로 생성되므로 제외
apiData.locations = data.orderItems
.filter((item) => !String(item.id).includes('-') && !isNaN(Number(item.id)))
.map((item) => ({
id: Number(item.id),
post_width: item.constructionWidth || null,
post_height: item.constructionHeight || null,
change_reason: item.changeReason || null,
inspection_data: item.inspectionData || null,
}));
}
const result = await executeServerAction<ProductInspectionApi>({
@@ -616,7 +624,7 @@ export async function updateInspection(
export async function saveLocationInspection(
docId: string,
locationId: string,
inspectionData: Record<string, unknown>,
inspectionData: ProductInspectionData,
constructionInfo?: {
width: number | null;
height: number | null;

View File

@@ -152,6 +152,7 @@ export interface ProductInspection {
qualityDocNumber: string; // 품질관리서 번호
siteName: string; // 현장명
client: string; // 수주처
clientId?: number; // 수주처 ID
locationCount: number; // 개소
requiredInfo: string; // 필수정보 (완료 / N건 누락)
inspectionPeriod: string; // 검사기간 (2026-01-01 또는 2026-01-01~2026-01-02)

View File

@@ -5,7 +5,7 @@
* - 왼쪽: 목록으로/취소 (뒤로가기 성격)
* - 오른쪽: [추가액션] 삭제 | 수정/저장/등록 (액션 성격)
*
* View 모드: 목록으로 | [추가액션] 삭제 | 수정
* View 모드: 목록으로 | [추가액션] 수정
* Edit 모드: 취소 | [추가액션] 삭제 | 저장
* Create 모드: 취소 | [추가액션] 등록
*/
@@ -156,8 +156,8 @@ export function DetailActions({
);
})}
{/* 삭제 버튼: view, edit 모드에서 표시 (create는 삭제 대상 없음) */}
{!isCreateMode && canDelete && showDelete && onDelete && (
{/* 삭제 버튼: edit 모드에서 표시 (view는 읽기 전용, create는 삭제 대상 없음) */}
{isEditMode && canDelete && showDelete && onDelete && (
<Button
variant="outline"
onClick={onDelete}

View File

@@ -59,6 +59,8 @@ import {
transformDailyAttendanceResponse,
} from '@/lib/api/dashboard/transformers';
import type { DashboardSectionKey } from '@/lib/dashboard-invalidation';
import type {
DailyReportData,
ReceivableData,
@@ -664,6 +666,7 @@ export interface CEODashboardState {
construction: SectionState<ConstructionData>;
dailyAttendance: SectionState<DailyAttendanceData>;
refetchAll: () => void;
refetchMap: Partial<Record<DashboardSectionKey, () => void>>;
}
export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashboardState {
@@ -782,6 +785,22 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dr.refetch, rv.refetch, dc.refetch, me.refetch, fetchCM, sb.refetch, ss.refetch, ps.refetch, dp.refetch, us.refetch, cs.refetch, da.refetch]);
// 섹션별 refetch 함수 맵 (targeted invalidation용)
const refetchMap = useMemo<Partial<Record<DashboardSectionKey, () => void>>>(() => ({
dailyReport: dr.refetch,
receivable: rv.refetch,
debtCollection: dc.refetch,
monthlyExpense: me.refetch,
cardManagement: fetchCM,
statusBoard: sb.refetch,
salesStatus: ss.refetch,
purchaseStatus: ps.refetch,
dailyProduction: dp.refetch,
unshipped: us.refetch,
construction: cs.refetch,
dailyAttendance: da.refetch,
}), [dr.refetch, rv.refetch, dc.refetch, me.refetch, 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 },
@@ -796,5 +815,6 @@ export function useCEODashboard(options: UseCEODashboardOptions = {}): CEODashbo
construction: { data: cs.data, loading: cs.loading, error: cs.error },
dailyAttendance: { data: da.data, loading: da.loading, error: da.error },
refetchAll,
refetchMap,
};
}

View File

@@ -93,31 +93,11 @@ function generateDebtCollectionCheckPoints(api: BadDebtApiResponse): CheckPoint[
return checkPoints;
}
// TODO: 백엔드 per-card sub_label/count 제공 시 더미값 제거
// 채권추심 카드별 더미 서브라벨 (회사명 + 건수)
const DEBT_COLLECTION_FALLBACK_SUB_LABELS: Record<string, { company: string; count: number }> = {
dc1: { company: '(주)부산화학 외', count: 5 },
dc2: { company: '(주)삼성테크 외', count: 3 },
dc3: { company: '(주)대한전자 외', count: 2 },
dc4: { company: '(주)한국정밀 외', count: 3 },
};
/**
* 채권추심 subLabel 생성 헬퍼
* dc1(누적)은 API client_count 사용, 나머지는 더미값
* 채권추심 subLabel: 백엔드 sub_labels 필드 직접 사용
*/
function buildDebtSubLabel(cardId: string, clientCount?: number): string | undefined {
const fallback = DEBT_COLLECTION_FALLBACK_SUB_LABELS[cardId];
if (!fallback) return undefined;
const count = cardId === 'dc1' && clientCount !== undefined ? clientCount : fallback.count;
if (count <= 0) return undefined;
const remaining = count - 1;
if (remaining > 0) {
return `${fallback.company} ${remaining}`;
}
return fallback.company.replace(/ 외$/, '');
function buildDebtSubLabel(cardId: string, subLabels?: Record<string, string | null>): string | undefined {
return subLabels?.[cardId] || undefined;
}
/**
@@ -130,25 +110,25 @@ export function transformDebtCollectionResponse(api: BadDebtApiResponse): DebtCo
id: 'dc1',
label: '누적 악성채권',
amount: api.total_amount,
subLabel: buildDebtSubLabel('dc1', api.client_count),
subLabel: buildDebtSubLabel('dc1', api.sub_labels),
},
{
id: 'dc2',
label: '추심중',
amount: api.collecting_amount,
subLabel: buildDebtSubLabel('dc2'),
subLabel: buildDebtSubLabel('dc2', api.sub_labels),
},
{
id: 'dc3',
label: '법적조치',
amount: api.legal_action_amount,
subLabel: buildDebtSubLabel('dc3'),
subLabel: buildDebtSubLabel('dc3', api.sub_labels),
},
{
id: 'dc4',
label: '회수완료',
amount: api.recovered_amount,
subLabel: buildDebtSubLabel('dc4'),
subLabel: buildDebtSubLabel('dc4', api.sub_labels),
},
],
checkPoints: generateDebtCollectionCheckPoints(api),

View File

@@ -14,42 +14,26 @@ import { normalizePath } from './common';
// ============================================
// 현황판 (StatusBoard)
// ============================================
// TODO: 백엔드 sub_label 필드 제공 시 더미값 제거
// API id 기준: orders, bad_debts, safety_stock, tax_deadline, new_clients, leaves, purchases, approvals
const STATUS_BOARD_FALLBACK_SUB_LABELS: Record<string, string> = {
orders: '(주)삼성전자 외',
bad_debts: '주식회사 부산화학 외',
safety_stock: '',
tax_deadline: '',
new_clients: '대한철강 외',
leaves: '',
// purchases: '(유)한국정밀 외', // [2026-03-03] 비활성화 — 백엔드 path 오류 + 데이터 정합성 이슈 (N4 참조)
approvals: '구매 결재 외',
};
//
// [대시보드 vs 원본 페이지 쿼리 조건 차이 — 건수 불일치는 버그 아님]
//
// | 항목 | 대시보드 조건 | 원본 페이지 |
// |----------------|---------------------------------------------------|------------------------------------------|
// | 수주 현황 | 오늘 날짜 + status=confirmed만 | /sales/order-management-sales (전체 기간) |
// | 채권 추심 | status=collecting + is_active=true만 | /accounting/bad-debt-collection (전체) |
// | 안전 재고 | safety_stock>0 && stock_qty<safety_stock (날짜무관) | /material/stock-status (날짜 필터 적용) |
// | 세금 신고 | 가장 가까운 tax 일정 D-day | /accounting/tax-invoices |
// | 신규 업체 | 최근 7일 이내 등록된 거래처만 | /accounting/vendors (전체 목록) |
// | 연차 현황 | 오늘 기준 approved 휴가만 | /hr/vacation-management (전체 기간) |
// | 발주 현황 | status=draft(임시저장)만 | /construction/order (전체 상태) |
// | 결재 요청 | 현재 로그인 사용자의 pending 결재만 | /approval/inbox (필터 조건 다름) |
//
/**
* 현황판 subLabel 생성 헬퍼
* API sub_label이 있으면 사용, 없으면 더미값 + 건수로 조합
* 현황판 subLabel: 백엔드 sub_label 필드 직접 사용
*/
function buildStatusSubLabel(item: { id: string; count: number | string; sub_label?: string }): string | undefined {
// API에서 sub_label 제공 시 우선 사용
if (item.sub_label) return item.sub_label;
// 건수가 0이거나 문자열이면 subLabel 불필요
const count = typeof item.count === 'number' ? item.count : parseInt(String(item.count), 10);
if (isNaN(count) || count <= 0) return undefined;
const fallback = STATUS_BOARD_FALLBACK_SUB_LABELS[item.id];
if (!fallback) return undefined;
// "대한철강 외" + 나머지 건수
const remaining = count - 1;
if (remaining > 0) {
return `${fallback} ${remaining}`;
}
// 1건이면 "외" 제거하고 이름만
return fallback.replace(/ 외$/, '');
function buildStatusSubLabel(item: { sub_label?: string }): string | undefined {
return item.sub_label || undefined;
}
/**

View File

@@ -107,6 +107,7 @@ export interface BadDebtApiResponse {
recovered_amount: number; // 회수완료
bad_debt_amount: number; // 대손처리
client_count?: number; // 거래처 수
sub_labels?: Record<string, string | null>; // 카드별 거래처 sub_label (dc1~dc4)
}
// ============================================

View File

@@ -42,7 +42,7 @@ function extractArray<T>(data: PaginatedOrArray<T>): T[] {
export async function fetchVendorOptions(): Promise<ActionResult<SelectOption[]>> {
const API_URL = process.env.NEXT_PUBLIC_API_URL;
const result = await executeServerAction({
url: `${API_URL}/api/v1/clients?per_page=100`,
url: `${API_URL}/api/v1/clients?size=1000`,
transform: (data: PaginatedOrArray<ClientApiItem>) => {
const clients = extractArray(data);
return clients.map(c => ({ id: String(c.id), name: c.name }));

View File

@@ -0,0 +1,111 @@
/**
* CEO 대시보드 targeted refetch 시스템
*
* CUD 발생 시 sessionStorage + CustomEvent로 대시보드 섹션별 갱신 트리거
*/
// 대시보드 섹션 키 (useCEODashboard의 refetchMap과 1:1 매핑)
export type DashboardSectionKey =
| 'dailyReport'
| 'receivable'
| 'debtCollection'
| 'monthlyExpense'
| 'cardManagement'
| 'statusBoard'
| 'salesStatus'
| 'purchaseStatus'
| 'dailyProduction'
| 'unshipped'
| 'construction'
| 'dailyAttendance'
| 'entertainment'
| 'welfare';
// CUD 도메인 → 영향받는 대시보드 섹션 매핑
type DomainKey =
| 'deposit'
| 'withdrawal'
| 'sales'
| 'purchase'
| 'badDebt'
| 'expectedExpense'
| 'bill'
| 'giftCertificate'
| 'journalEntry'
| 'order'
| 'stock'
| 'schedule'
| 'client'
| 'leave'
| 'approval'
| 'attendance'
| 'production'
| 'shipment'
| 'construction';
const DOMAIN_SECTION_MAP: Record<DomainKey, DashboardSectionKey[]> = {
deposit: ['dailyReport', 'receivable'],
withdrawal: ['dailyReport', 'monthlyExpense'],
sales: ['dailyReport', 'salesStatus', 'receivable'],
purchase: ['dailyReport', 'purchaseStatus', 'monthlyExpense'],
badDebt: ['debtCollection', 'receivable'],
expectedExpense: ['monthlyExpense'],
bill: ['dailyReport', 'receivable'],
giftCertificate: ['entertainment', 'cardManagement'],
journalEntry: ['entertainment', 'welfare', 'monthlyExpense'],
order: ['statusBoard', 'salesStatus'],
stock: ['statusBoard'],
schedule: ['statusBoard'],
client: ['statusBoard'],
leave: ['statusBoard', 'dailyAttendance'],
approval: ['statusBoard'],
attendance: ['statusBoard', 'dailyAttendance'],
production: ['statusBoard', 'dailyProduction'],
shipment: ['statusBoard', 'unshipped'],
construction: ['statusBoard', 'construction'],
};
const STORAGE_KEY = 'dashboard:stale-sections';
const EVENT_NAME = 'dashboard:invalidate';
/**
* CUD 성공 후 호출 — 해당 도메인이 영향 주는 대시보드 섹션을 stale 처리
*/
export function invalidateDashboard(domain: DomainKey): void {
const sections = DOMAIN_SECTION_MAP[domain];
if (!sections || sections.length === 0) return;
// 1. sessionStorage에 stale 섹션 저장 (navigation 사이 유지)
try {
const existing = sessionStorage.getItem(STORAGE_KEY);
const current: string[] = existing ? JSON.parse(existing) : [];
const merged = Array.from(new Set([...current, ...sections]));
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
} catch {
// sessionStorage 접근 불가 시 무시
}
// 2. CustomEvent 발행 (대시보드가 마운트 중이면 즉시 처리)
if (typeof window !== 'undefined') {
window.dispatchEvent(
new CustomEvent(EVENT_NAME, { detail: { sections } }),
);
}
}
/**
* 대시보드 마운트 시 호출 — stale 섹션 읽고 클리어
*/
export function consumeStaleSections(): DashboardSectionKey[] {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return [];
sessionStorage.removeItem(STORAGE_KEY);
return JSON.parse(raw) as DashboardSectionKey[];
} catch {
return [];
}
}
/** CustomEvent 이름 (리스너 등록용) */
export const DASHBOARD_INVALIDATE_EVENT = EVENT_NAME;