58 Commits

Author SHA1 Message Date
563b240fbf feat: [품질검사] 검사 모달 개선 + 수주 선택 필터링
검사 모달:
- 기본값 null(미선택)으로 변경, 일괄합격/초기화 토글 버튼
- 시공 가로/세로, 변경사유 입력 필드 추가
- 검사 항목별 기준값 텍스트 표시
- 사진 첨부 기능 (최대 2장, base64)
- 이전/다음 개소 네비게이션 + 자동저장

뱃지/상태:
- legacy 검사 데이터 반영 (합격/불합격/진행중/미검사)
- 사진 없으면 진행중 처리, 뱃지 크기 통일
- Eye 아이콘 → "보기" 텍스트 뱃지
- 진행바 legacy+FQC 통합 inspectionStats

수주 선택:
- 같은 거래처(발주처) + 같은 모델만 선택 가능 필터링
- 수주 선택 시 개소별 자동 펼침 (floor, symbol, 규격 포함)
- 모달에 모델명 컬럼 추가, 필터 적용 시 제목에 안내 표시
- 변경사유 서버 저장 연동 수정
2026-03-07 01:19:17 +09:00
e75d8f9b25 fix: [제품검사 요청서] EAV 문서 없을 때 legacy fallback 적용
- useFqcMode && fqcDocument 조건으로 변경
- requestDocumentId 없는 기존 데이터에서 빈 양식 표시되던 문제 수정
2026-03-06 22:02:39 +09:00
4ea03922a3 feat: [제품검사 성적서] 8컬럼 동적 렌더링 + FQC 모드 기본값
- FqcDocumentContent: 8컬럼 시각 레이아웃 (No/검사항목/세부항목/검사기준/검사방법/검사주기/측정값/판정)
- rowSpan 병합: category 단독 + method+frequency 복합키 병합
- measurement_type: checkbox→양호/불량, numeric→숫자입력, none→비활성
- InspectionReportModal: FQC 모드 우선 (template 로드 실패 시 legacy fallback)
- Lazy Snapshot 준비 (contentWrapperRef 추가)
2026-03-06 21:47:33 +09:00
295585d8b6 feat: 제품검사 요청서 양식 기반 렌더링 + Lazy Snapshot
- FqcRequestDocumentContent: template 66 기반 동적 렌더링 컴포넌트
  - 결재라인, 기본정보, 입력사항(4섹션), 사전고지 테이블
  - group_name 기반 3단 헤더 (오픈사이즈 발주/시공 병합)
- InspectionRequestModal: FQC 모드 전환 + EAV 문서 로드 + Lazy Snapshot
- fqcActions: getFqcRequestTemplate, patchDocumentSnapshot, description/groupName 타입
- types/actions: requestDocumentId 필드 추가 및 매핑
- InspectionDetail: requestDocumentId prop 전달
2026-03-06 21:43:01 +09:00
e7263feecf feat: [품질관리] 수주 연결 동기화 + 개소별 데이터 저장
- transformApiToFrontend에 orderId, inspectionData 매핑 추가
- transformFormToApi에 order_ids 추가
- updateInspection에 order_ids 동기화 + locations 데이터 전송
2026-03-06 21:09:48 +09:00
8250eaf2b5 feat: [문서스냅샷] Lazy Snapshot - 중간검사/작업일지 조회 시 자동 스냅샷 캡처
- patchDocumentSnapshot() 서버 액션 추가
- InspectionReportModal: resolve 응답의 snapshot_document_id 기반 Lazy Snapshot
- WorkLogModal: getWorkLog으로 문서 확인 후 Lazy Snapshot
- 동작: rendered_html NULL → 500ms 후 innerHTML 캡처 → 백그라운드 PATCH
2026-03-06 20:59:25 +09:00
72a2a3e9a9 fix: [문서스냅샷] 캡처 방식 보정 - 오프스크린 성적서 렌더링, readOnly 자동 캡처 제거
- ImportInspectionInputModal: 입력폼 캡처 → 오프스크린 성적서 문서 렌더링으로 변경
- InspectionReportModal: readOnly 자동 캡처 useEffect 제거 (불필요 PUT 방지)
- capture-rendered-html.tsx: 오프스크린 렌더링 유틸리티 신규 추가
2026-03-06 20:35:30 +09:00
31f523c88f feat: [문서] 모든 문서 저장 경로에 rendered_html 스냅샷 캡처 추가
- InspectionReportModal: readOnly 모드에서도 콘텐츠 로드 후 자동 캡처
- ImportInspectionInputModal: 수입검사 저장 시 폼 HTML 캡처 전송
- ReceivingManagement/actions: saveInspectionData에 rendered_html 파라미터 추가
2026-03-06 20:04:11 +09:00
a1fb0d4f9b feat: [문서] 검사성적서/작업일지 저장 시 HTML 스냅샷 캡처 전송
- InspectionReportModal: contentWrapperRef로 DOM 캡처, handleSave에서 rendered_html 포함
- WorkLogModal: contentWrapperRef로 DOM 캡처, handleSave에서 rendered_html 포함
- saveInspectionDocument/saveWorkLog 타입에 rendered_html 추가
- MNG에서 스냅샷 기반 문서 출력을 위한 프론트 파이프라인 완성
2026-03-06 17:46:06 +09:00
fe930b5831 feat: [품질관리] 수주선택 모달 발주처별 비활성화 제약 추가
- SearchableSelectionModal에 isItemDisabled 콜백 prop 추가 (공통)
  - renderItem에 isDisabled 3번째 파라미터 전달 (하위호환)
  - disabled 아이템 클릭 차단 + opacity/cursor 스타일 적용
  - 전체선택 시 disabled 아이템 제외
- OrderSelectModal: 선택된 발주처와 다른 발주처의 수주 비활성화
  - 이미 선택된 아이템은 해제 가능 (disabled 예외)
2026-03-05 23:15:17 +09:00
899493a74d feat: [품질관리] 수주선택 모달에 발주처 컬럼 추가
- OrderSelectItem 타입에 clientName 필드 추가
- actions.ts API 응답 매핑에 client_name → clientName 추가
- OrderSelectModal 테이블 헤더/바디에 발주처 컬럼 추가
- 모달 너비 sm:max-w-2xl → sm:max-w-3xl 확장
2026-03-05 23:09:05 +09:00
45ad99cb38 fix: [공통] SearchableSelectionModal 테이블 HTML 유효성 에러 수정
- renderItem이 <tr>을 반환할 때 <div>로 래핑하여 발생하던 hydration 에러 해결
- cloneElement로 key/onClick을 직접 주입하여 <tbody><div><tr> 구조 방지
- 영향범위: OrderSelectModal, SalesOrderSelectModal, TaxInvoiceIssuance
2026-03-05 21:59:44 +09:00
10c6e20db4 fix: [품질관리] 실적신고 API 응답 snake_case → camelCase 변환 추가
- transformReport: 실적신고 목록 데이터 변환
- transformStats: 통계 데이터 변환
- transformMissedReport: 누락체크 데이터 변환
- 백엔드 snake_case 응답을 프론트 camelCase 타입에 매핑
2026-03-05 21:26:01 +09:00
50e4c72c8a feat: [품질관리] 프론트엔드 API 연동 (Mock → 실제 API 전환)
- InspectionManagement/actions.ts: API 경로 /quality/documents로 변경, transformFormToApi options JSON 구조 매핑
- PerformanceReportManagement/actions.ts: API 경로 /quality/performance-reports로 변경, /missed→/missing
- InspectionManagement/types.ts: InspectionFormData에 clientId/inspectorId/receptionDate 추가
- USE_MOCK_FALLBACK = false 설정
2026-03-05 21:26:01 +09:00
eb18a3facb fix: [생산지시] BOM 공정 분류 UI 수정 + 접이식 카드
- BOM types/actions: 필드 매핑 수정 (unit, quantity, unitPrice, totalPrice, nodeName)
- BOM UI: 접이식(collapsible) Card + ChevronDown 토글
- 테이블 컬럼 변경: 품목코드, 품목명, 규격, 단위, 수량, 단가, 금액, 개소
- 중복 key 수정: `${item.id}-${idx}` 패턴 적용
2026-03-05 21:26:01 +09:00
9fc979e135 fix: [생산지시] 날짜포맷·수량→개소수·중복key 수정
- actions.ts: formatDateOnly 정규식 보강 (공백/T 구분자 모두 처리)
- actions.ts: nodeCount 매핑 추가 (node_count/nodes_count)
- types.ts: nodeCount, node_count, nodes_count 필드 추가
- page.tsx: 수량→개소 컬럼 변경, nodeCount 표시
- [id]/page.tsx: 수량→개소 표시, BOM 테이블 중복 key 수정
2026-03-05 21:26:01 +09:00
fa7efb7b24 feat: [생산지시] 목록/상세 페이지 API 연동
- types.ts: API/프론트 타입 정의 (ProductionOrder, Detail, BOM 타입)
- actions.ts: Server Actions (getProductionOrders, getProductionOrderStats, getProductionOrderDetail)
  - executePaginatedAction + buildApiUrl 패턴 적용
  - snake_case → camelCase 변환 함수
- 목록 page.tsx: 샘플데이터 → API 연동
  - 서버사이드 페이지네이션 (clientSideFiltering: false)
  - stats API로 탭 카운트 동적 반영
  - ProgressSteps 동적화 (statusCode 기반)
  - 생산지시번호 → 수주번호로 변경 (별도 PO 번호 없음)
- 상세 page.tsx: 샘플데이터 → API 연동
  - getProductionOrderDetail() API 호출
  - createProductionOrder() orders/actions.ts에서 재사용
  - BOM null 처리 (빈 상태 표시)
  - WorkOrder 상태 배지 확장 (6종: unassigned~shipped)
2026-03-05 21:26:01 +09:00
유병철
bec933b3b4 refactor: CEO 대시보드 mockData/modalConfigs 정리 및 BillManagement 간소화
- mockData 불필요 데이터 대폭 제거
- modalConfigs (cardManagement, entertainment, monthlyExpense, vat, welfare) 정리
- CEODashboard 컴포넌트 개선
- BillManagementClient 간소화
2026-03-05 21:22:17 +09:00
유병철
1675f3edcf feat: 어음관리 리팩토링 및 CEO 대시보드 SummaryNavBar 추가
- BillManagement: BillDetail 리팩토링, sections/hooks 분리, constants 추가
- BillManagement types 대폭 확장, actions 개선
- GiftCertificateManagement: actions/types 확장
- CEO 대시보드: SummaryNavBar 컴포넌트 추가, useSectionSummary 훅
- bill-prototype 개발 페이지 업데이트
2026-03-05 20:47:43 +09:00
유병철
2fe47c86d3 refactor: VehicleDispatch actions 불필요 코드 제거 2026-03-05 13:38:16 +09:00
유병철
00a6209347 feat: 레이아웃/출하/생산/회계/대시보드 전반 개선
- HeaderFavoritesBar 대폭 개선
- Sidebar/AuthenticatedLayout 소폭 수정
- ShipmentCreate, VehicleDispatch 출하 관련 개선
- WorkOrderCreate/Edit, WorkerScreen 생산 관련 개선
- InspectionCreate 자재 입고검사 개선
- DailyReport, VendorDetail 회계 수정
- CEO 대시보드: CardManagement/DailyProduction/DailyAttendance 섹션 개선
- useCEODashboard, expense transformer 정비
- DocumentViewer, PDF generate route 소폭 수정
- bill-prototype 개발 페이지 추가
- mockData 불필요 데이터 제거
2026-03-05 13:35:48 +09:00
c18c68b6b7 chore: [infra] Slack 알림 채널 분리 — product_infra → deploy_react 2026-03-05 11:32:17 +09:00
03d129c32c fix: [outbound] 출하관리 캘린더 기본 뷰 week-time으로 변경 2026-03-05 11:01:27 +09:00
d6e3131c6a fix: [production] 절곡 중간검사 데이터 새로고침 시 초기화 버그 수정
- InspectionInputModal: 이전 형식 데이터(products 배열 없음) 로드 시 judgment 기반 제품별 상태 추론
- InspectionInputModal: skipAutoJudgmentRef로 이전 형식 로드 시 auto-judgment 덮어쓰기 방지
- BendingInspectionContent: products/bendingStatus 없을 때 judgment 기반 fallback 추가
2026-03-05 10:39:45 +09:00
1d3805781c feat: [outbound] 배차차량관리 목업→API 연동 전환
- mockData 제거, executePaginatedAction/executeServerAction 사용
- buildApiUrl로 쿼리 파라미터 빌드
- API 응답(snake_case) → 프론트 타입(camelCase) 변환 함수 추가
2026-03-04 23:36:20 +09:00
b45c35a5e8 fix: [production] 절곡 중간검사 수주 단위 데이터 공유 모델 적용
- 로드 경로: 절곡 공정 시 어떤 item이든 inspection_data 있으면 모든 개소에 공유
- 저장 경로: 절곡 검사 완료 시 inspectionDataMap에 모든 workItem 동기화
- TemplateInspectionContent: products 배열 우선 복원 (EAV 문서 데이터보다 우선)
- workOrderId prop 추가 (절곡 gap_points API 동적 로딩)
2026-03-04 23:27:12 +09:00
b05e19e9f8 fix: [quality] QMS mockData에 productCode 필드 누락 수정
WorkOrder 타입 필수 필드 productCode 추가하여 빌드 오류 해결
2026-03-04 22:40:23 +09:00
4331b84a63 feat: [production] 절곡 중간검사 입력 모달 — 7개 제품 항목 통합 및 성적서 데이터 연동
- InspectionInputModal: 절곡 전용 7개 제품별 입력 폼 (절곡상태/길이/너비/간격)
- TemplateInspectionContent: products 배열 → bending cellValues 자동 매핑
- 제품 ID 3단계 매칭 (정규화→키워드→인덱스 폴백)
- 절곡 작업지시서 bending 섹션 개선
2026-03-04 22:28:16 +09:00
0b81e9c1dd feat: [process] 공정 단계에 검사범위(InspectionScope) 설정 추가
- 전수검사/샘플링/그룹 유형 선택 UI
- 샘플링 시 샘플 크기(n) 입력
- options JSON으로 API 저장/복원
2026-03-04 22:28:16 +09:00
f653960a30 fix: [shipment] 배차 상세/수정 기본정보 그리드 레이아웃 개선 (1열→2x4열) 2026-03-04 22:28:16 +09:00
888fae119f chore: next dev에서 --turbo 플래그 제거 2026-03-04 22:28:16 +09:00
f503e20030 fix: [production] 작업자 화면 하드코딩 도면 이미지 영역 제거
- BendingExtraInfo, WipExtraInfo에서 drawingUrl 도면 이미지 div 제거
- types.ts에서 drawingUrl 필드 제거
- actions.ts, index.tsx에서 drawing_url 매핑 제거
2026-03-04 22:28:16 +09:00
0166601be8 fix: [production] 자재투입 모달 — 동일 자재 다중 BOM 그룹 LOT 독립 관리
- getLotKey에 groupKey 포함하여 그룹별 LOT 선택/배정 독립 처리
- physicalUsed 맵으로 물리LOT 교차그룹 가용량 추적
- handleAutoFill FIFO 자동입력 (교차그룹 가용량 고려)
- handleSubmit 그룹별 개별 엔트리 전송 (bom_group_key 포함, replace 모드)
- 기투입 LOT 자동 선택 및 배지 표시, 수량 수동 편집 input
- allGroupsFulfilled 조건으로 투입 버튼 활성화 제어
- actions.ts: lotInputtedQty 필드 + bom_group_key/replace 파라미터 추가
2026-03-04 22:28:16 +09:00
83a23701a7 feat: [shipment] 배차정보 다중 행 API 연동 — actions.ts transform 함수 수정
- ShipmentApiData에 vehicle_dispatches 타입 추가
- transformApiToDetail: vehicle_dispatches 배열 매핑 (레거시 단일필드 fallback 유지)
- transformCreateFormToApi/transformEditFormToApi: vehicleDispatches → vehicle_dispatches 변환 추가
- transformApiToListItem: 첫 번째 배차의 arrival_datetime 반영
2026-03-04 22:28:16 +09:00
bedfd1f559 fix: [production] 작업자 화면 제품명 표시 간소화 — productCode만 표시
작업목록, 상세카드, 자재투입, 중간검사 모달에서 부품 목록까지 길게
표시되던 제품명을 productCode만 표시하도록 변경
2026-03-04 22:28:16 +09:00
8bcabafd08 fix: [production] 자재투입 모달 — bomGroupKey 그룹핑, 카테고리 순서 정렬, 번호 표시
- bomGroupKey 기반 그룹핑 (같은 item_id라도 category+partType별 분리)
- 카테고리 순서 정렬 (가이드레일→하단마감재→셔터박스→연기차단재)
- 카테고리 내 원형번호(①②③) 표시
- partType 배지 추가
- MaterialForItemInput에 bomGroupKey 필드 추가
2026-03-04 22:28:16 +09:00
5ff5093d7b fix: [출고관리] 목록 테이블 수신자/수신주소/수신처/작성자/출고일 API 매핑 연동
- OrderInfoApiData에 writer_name, writer_id, delivery_date 필드 추가
- transformApiToListItem에서 5개 필드 매핑 누락 수정
2026-03-04 22:28:16 +09:00
유병철
23fa9c0ea2 feat: CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 개선 및 회계 페이지 확장
- 접대비/복리후생비 섹션: 리스크감지형 구조로 변경
- 매출채권 섹션: transformer/타입 정비
- 캘린더 섹션: ScheduleDetailModal 개선
- 카드관리 모달 transformer 확장
- useCEODashboard 훅 리팩토링 및 정리
- dashboard endpoints/types/transformers (expense, receivable, tax-benefits) 대폭 확장
- 회계 5개 페이지(은행거래, 카드거래, 매출채권, 세금계산서, 거래처원장) 기능 개선
- ApprovalBox 소폭 수정
- CLAUDE.md 업데이트
2026-03-04 22:19:10 +09:00
유병철
cde9333652 feat: CEO 대시보드 API 연동 강화 및 회계/결재/HR 개선
- CEO 대시보드: 예상비용, 현황이슈, 일별매출/매입 등 모달 API 연동 확대
- dashboard transformers 리팩토링 (hr, sales-purchase, production-logistics 분리)
- useCEODashboard 훅 대폭 확장 (모달 데이터 fetching 로직)
- DailyReport: USD 섹션 추가 및 레이아웃 개선
- VendorManagement/ApprovalBox: 소폭 개선
- VacationManagement: 소폭 수정
- component-registry previews 업데이트
- claudedocs: 대시보드 API 스펙, 분석 문서 추가
2026-03-03 22:18:48 +09:00
유병철
7bb8699403 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-react-prod into develop 2026-03-01 12:17:47 +09:00
유병철
1bccaffe27 feat: CEO 대시보드 리팩토링 및 회계 관리 개선
- CEO 대시보드: 컴포넌트 분리(DashboardSettingsSections, DetailModalSections), 모달/섹션 개선, useCEODashboard 최적화
- 회계: 부실채권/매출/매입/일일보고 UI 및 타입 개선
- 공통: Sidebar, useDashboardFetch 훅 추가, amount/status-config 유틸 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 12:17:40 +09:00
7a8d946960 merge: main의 검사문서/생산/결재 커밋을 develop으로 이동 2026-02-27 23:22:32 +09:00
d1c530fdc1 feat: [결재] 결재함에서 검사성적서 템플릿 기반 렌더링 + 결재 상신 기능
- 결재함에서 work_order 연결 문서 클릭 시 InspectionReportModal(readOnly)로 표시
  - 기존 LinkedDocumentContent(key-value)가 아닌 템플릿 기반 검사성적서 형태로 표시
  - getDocumentApprovalById에서 document.linkable_type/linkable_id로 workOrderId 추출
  - field_value 컬럼명 매칭 수정 (d.value → d.field_value ?? d.value)
- InspectionReportModal에 결재 상신 버튼 추가 (DRAFT 상태에서만 표시)
- submitDocumentForApproval 서버 액션 추가
- LinkedDocumentContent 컴포넌트 신규 (일반 문서용 폴백)
- DocumentType에 'document' 타입 추가, LinkedDocumentData 인터페이스 신규
2026-02-27 23:18:02 +09:00
0f53b407db feat: [inspection] InspectionConfigData에 finishing_type 필드 추가
- API 응답의 마감유형(S1/S2/S3) 정보를 타입에 반영
2026-02-27 23:18:02 +09:00
0da6586bb6 feat: [inspection] Phase 3 TemplateInspectionContent API 연동
- getInspectionConfig Server Action 추가
  - InspectionConfigData/Item/GapPoint 타입 정의
- TemplateInspectionContent API 연동
  - inspectionConfig state + useEffect로 API 호출
  - bendingProducts: API 우선 → buildBendingProducts fallback
  - bending_info에서 dimension 보조 데이터 추출
2026-02-27 23:18:02 +09:00
2c87ac535a fix: [production] product_code 표시 소스 개선
- WorkerScreen/ProductionDashboard에서 options.product_code 우선 사용
- fallback: sales_order.item.code (기존 방식)
- Dashboard items 타입에 options 필드 추가
2026-02-27 23:18:02 +09:00
9ae2210388 feat: [생산] 제품코드(productCode) 표시 추가
- ProductionDashboard, WorkerScreen 타입/변환에 productCode 필드 추가
- WorkOrderListPanel 목록에 제품코드 - 제품명 형태로 표시
- WorkerScreen 검사 항목에 제품코드 포함
2026-02-27 23:18:02 +09:00
33f763b48f fix: [검사문서] bending 개소별 저장 fallback 조건 수정
- isBending이지만 bendingProducts가 없는 경우에도 기존 개소별 저장 동작하도록 조건 변경
- Before: if (!isBending) → 절곡이면 무조건 skip
- After: if (!isBending || bendingProducts.length === 0) → 구성품 없으면 개소별 fallback
2026-02-27 23:18:02 +09:00
유병철
8c0a655906 Merge branch 'main' into develop 2026-02-27 18:17:49 +09:00
유병철
f4a7374f8c merge: main을 develop에 머지 (충돌 해결: SalaryManagement) 2026-02-27 12:29:21 +09:00
유병철
9d66d554ec feat: 회계/급여 관리 개선 및 공통 템플릿 보강
- 회계: 매출/청구/입출금 관리 UI 개선
- 급여: SalaryDetailDialog 대폭 개선, SalaryRegistrationDialog 신규
- 공통: IntegratedDetailTemplate, UniversalListPage 보강
- UI: currency-input 컴포넌트 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 12:26:15 +09:00
유병철
b1686aaf66 feat: 모바일 반응형 UI 개선 및 공휴일/일정 시스템 통합
- MobileCard 접기/펼치기(collapsible) 기능 추가 및 반응형 레이아웃 개선
- DatePicker 공휴일/세무일정 색상 코딩 통합, DateTimePicker 신규 추가
- useCalendarScheduleInit 훅으로 전역 공휴일/일정 데이터 캐싱
- 전 도메인 날짜 필드 DatePicker 표준화 (104 files)
- 생산대시보드/작업지시 모바일 호환성 강화
- 견적서/주문관리 반응형 그리드 적용
- 회계 모듈 기능 개선 (매입상세 결재연동, 미수금현황 조회조건 등)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 21:27:40 +09:00
2777ecf664 Revert "feat: [employee] 사원관리 정렬 옵션에 퇴직일자 추가 및 기본 정렬을 입사일 빠른순으로 변경"
This reverts commit 7aefbafb6f.
2026-02-26 19:36:04 +09:00
김보곤
7aefbafb6f feat: [employee] 사원관리 정렬 옵션에 퇴직일자 추가 및 기본 정렬을 입사일 빠른순으로 변경
- 기본 정렬: 직급순 → 입사일 빠른순(hireDateAsc)
- 퇴직일자 최신순/빠른순 정렬 옵션 추가
- 정렬 옵션 순서 재배치 (입사일/퇴직일 우선)
2026-02-26 19:21:56 +09:00
김보곤
a83a8298d2 fix: [calendar] 대량 등록 다이얼로그 기존 데이터 표시 기능 추가
- BulkRegistrationDialog에 schedules prop 추가
- 다이얼로그 열릴 때 기존 등록 데이터를 텍스트로 변환하여 표시
- MNG 대량 등록과 동일한 동작
2026-02-26 15:25:36 +09:00
김보곤
7af1c75eea feat: [calendar] 달력 일정 관리 API 연동 활성화
- loadData 함수의 API 호출 주석 해제
- getCalendarSchedules, getCalendarStats 실제 호출
2026-02-26 14:29:22 +09:00
유병철
8d8e2be001 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-react-prod into develop 2026-02-25 22:30:16 +09:00
유병철
8f9507a665 feat: 다중 도메인 UI 개선 및 컴포넌트 리팩토링
- 게시판, HR, 설정, 차량관리, 건설, 견적 등 전반적 UI 개선
- FormField, TabChip, Select 등 공통 컴포넌트 개선
- 가격배분 edit 페이지 제거 및 상세 페이지 통합
- 체크리스트, 근태, 급여, 권한 관리 등 폼 개선

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:30:06 +09:00
171 changed files with 15470 additions and 7461 deletions

View File

@@ -107,3 +107,10 @@ fixed_tools: []
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:

View File

@@ -326,16 +326,19 @@ const [data, setData] = useState(() => {
---
## Backend API Analysis Policy
## Backend API Policy
**Priority**: 🟡
- Backend API 코드는 **분석만**, 직접 수정 안 함
- 수정 필요 시 백엔드 요청 문서로 정리:
- **신규 API 생성 금지**: 새로운 엔드포인트/컨트롤러 생성은 직접 하지 않음 → 요청 문서로 정리
- **기존 API 수정/추가 가능**: 이미 존재하는 API의 수정, 필드 추가, 로직 변경은 직접 수행 가능
- 백엔드 경로: `sam_project/sam-api/sam-api` (PHP Laravel)
- 수정 시 기존 코드 패턴(Service-First, 기존 응답 구조) 준수
- 신규 API가 필요한 경우 요청 문서로 정리:
```markdown
## 백엔드 API 수정 요청
### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX)
### 현재 문제: [설명]
### 수정 요청: [내용]
## 백엔드 API 신규 요청
### 엔드포인트: [HTTP METHOD /api/v1/path]
### 목적: [설명]
### 요청/응답 구조: [내용]
```
---

6
Jenkinsfile vendored
View File

@@ -17,7 +17,7 @@ pipeline {
script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
}
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_react', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
@@ -128,11 +128,11 @@ pipeline {
post {
success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}

View File

@@ -0,0 +1,172 @@
# 일일일보 — USD(외국환) 섹션 누락
**유형**: 프론트엔드 UI 누락
**파일**: `src/components/accounting/DailyReport/index.tsx`
**날짜**: 2026-03-03
---
## 현상
일일일보 페이지에 KRW(원화) 계좌만 표시되고, USD(외국환) 계좌 섹션이 없음.
summary에 `usd_totals`(이월/입금/출금/잔액)이 내려오고, daily-accounts에 `currency: 'USD'` 항목도 내려오지만 UI에서 렌더링하지 않음.
---
## 원인
모든 테이블에서 `currency === 'KRW'` 필터만 적용 중:
```tsx
// line 391 — 계좌별 상세
filteredDailyAccounts.filter(item => item.currency === 'KRW')
// line 448 — 입금 테이블
filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0)
// line 497 — 출금 테이블
filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0)
```
---
## 요구사항
기존 KRW 섹션과 동일한 구조로 USD 섹션 추가:
### 1. 일자별 상세 테이블에 USD 행 추가
- 기존 KRW 계좌 목록 아래에 USD 계좌 목록 표시
- 또는 KRW/USD 구분 소계 행으로 분리
- 합계: `accountTotals.usd` 사용 (이미 계산 로직 있음, line 134-144)
### 2. 예금 입출금 내역에 USD 입금/출금 테이블 추가
- 기존 KRW 입금/출금 아래에 USD 입금/출금 테이블 추가
- 필터: `currency === 'USD' && item.income > 0` / `currency === 'USD' && item.expense > 0`
- 금액 표시: USD 포맷 ($ 또는 달러 표기)
---
## 참고: 이미 준비된 데이터
### summary에서 내려오는 USD 데이터 (line 53-58)
```typescript
summary: {
krwTotals: { carryover, income, expense, balance }, // ← 현재 사용 중
usdTotals: { carryover, income, expense, balance }, // ← 미사용 (여기 추가)
}
```
### accountTotals 계산 로직 (line 134-144)
```typescript
// 이미 USD 합계 계산이 있음 — 사용만 하면 됨
const usdAccounts = dailyAccounts.filter(item => item.currency === 'USD');
const usdTotal = usdAccounts.reduce(
(acc, item) => ({
carryover: acc.carryover + item.carryover,
income: acc.income + item.income,
expense: acc.expense + item.expense,
balance: acc.balance + item.balance,
}),
{ carryover: 0, income: 0, expense: 0, balance: 0 }
);
// accountTotals.usd 로 접근 가능
```
---
## 작업 범위
| 작업 | 설명 |
|------|------|
| 일자별 상세 테이블 | USD 계좌 행 추가 + USD 소계 행 |
| 입금 테이블 | USD 입금 내역 추가 |
| 출금 테이블 | USD 출금 내역 추가 |
| 금액 포맷 | USD 표시 (달러 기호 또는 통화 표기) |
**수정 파일**: `src/components/accounting/DailyReport/index.tsx` (이 파일만)
**새 코드 불필요**: API 데이터, 타입, 계산 로직 모두 이미 있음. 렌더링만 추가.
**상태**: ✅ 완료 (프론트엔드 렌더링 추가됨)
---
# CEO 대시보드 — 자금현황 데이터 정합성 이슈
**유형**: 백엔드 데이터 불일치
**관련 API**: `GET /api/proxy/daily-report/summary`
**관련 파일**: `sam-api/app/Services/DailyReportService.php`
**날짜**: 2026-03-03
---
## 현상
CEO 대시보드 자금현황 섹션의 **입금 합계**가 입금 관리 페이지(`/accounting/deposits`)의 실제 데이터와 불일치.
| 항목 | 대시보드 summary API | 입금 관리 페이지 API | 차이 |
|------|---------------------|---------------------|------|
| 3월 입금 합계 | **200,000원** | **50,000원** (1건) | **150,000원 차이** |
| 3월 출금 합계 | 50,000원 | 50,000원 (1건) | 일치 |
---
## 자금현황 각 수치의 의미 (현재 구조)
```
현금성 자산 합계 (cash_asset_total)
= KRW 활성 계좌들의 누적 잔액 합계 (당월만이 아닌 전체 잔고)
├── 전월이월(carryover): 49,872,638원 ← 3월 이전 누적 (입금총액 - 출금총액)
├── 당월입금(income): 200,000원 ← 3월 1일~오늘 입금
├── 당월출금(expense): 50,000원 ← 3월 1일~오늘 출금
└── 잔액(balance): 50,022,638원 = 이월+입금-출금
외국환(USD) 합계 (foreign_currency_total) = USD 계좌 잔액 합계
입금 합계 = krw_totals.income (당월 KRW 입금만)
출금 합계 = krw_totals.expense (당월 KRW 출금만)
```
---
## 원인 분석
### 대시보드 summary API 쿼리 (DailyReportService.php line 77-80)
```php
$income = Deposit::where('tenant_id', $tenantId)
->where('bank_account_id', $account->id)
->whereBetween('deposit_date', [$startOfMonth, $endOfDay])
->sum('amount');
```
### 입금 관리 페이지 API 쿼리
- 별도 컨트롤러/서비스에서 조회
- 동일한 `deposits` 테이블을 읽지만, 조회 조건이 다를 수 있음
### 불일치 가능 원인
1. **soft delete 차이**: summary는 soft-deleted 레코드 포함, 목록 API는 제외
2. **tenant_id 조건 차이**: 두 API의 tenant 필터링이 다를 수 있음
3. **E2E 테스트 데이터**: 테스트가 DB에 직접 삽입한 레코드가 목록 API에서는 필터됨
4. **status 필터**: 입금 관리 목록에 status 조건이 추가되어 일부 제외
---
## 확인 필요 사항 (백엔드)
### 1. deposits 테이블 직접 조회
```sql
SELECT id, deposit_date, amount, bank_account_id, deleted_at, status
FROM deposits
WHERE tenant_id = [현재테넌트]
AND bank_account_id = 1
AND deposit_date BETWEEN '2026-03-01' AND '2026-03-03'
ORDER BY id;
```
→ 실제 레코드 수와 합계 확인 (soft delete, status 포함)
### 2. 두 API의 쿼리 조건 비교
- `DailyReportService::dailyAccounts()` — Deposit 모델 조건
- 입금 관리 컨트롤러/서비스 — Deposit 모델 조건
- 차이점 확인 (withTrashed, status 등)
### 3. 해결 방향
- 두 API가 동일한 데이터 소스를 보도록 통일
- 또는 대시보드에서 기존 입금/출금 관리 API를 재사용하여 데이터 일관성 확보

View File

@@ -0,0 +1,52 @@
# 백엔드 API 수정 요청: 당월 예상 지출 상세 - 날짜 범위 필터링
## 엔드포인트
`GET /api/v1/expected-expenses/dashboard-detail`
## 현재 상태
- `transaction_type` 파라미터만 지원 (purchase, card, bill)
- `start_date`, `end_date` 파라미터를 **무시**함
- `items` 배열이 항상 **당월(현재 월)** 기준으로만 반환됨
- `summary`도 당월 기준 고정 (total_amount, change_rate 등)
- `monthly_trend`만 여러 월 데이터 포함 (최근 7개월)
## 요청 내용
### 1. 날짜 범위 필터 지원 추가
```
GET /api/v1/expected-expenses/dashboard-detail?transaction_type=purchase&start_date=2026-01-01&end_date=2026-01-31
```
| 파라미터 | 타입 | 설명 | 기본값 |
|---------|------|------|--------|
| `start_date` | string (yyyy-MM-dd) | 조회 시작일 | 당월 1일 |
| `end_date` | string (yyyy-MM-dd) | 조회 종료일 | 당월 말일 |
| `search` | string | 거래처/항목 검색 | (없음) |
### 2. 기대 동작
- `items`: `start_date` ~ `end_date` 범위의 거래 내역만 반환
- `summary.total_amount`: 해당 기간의 합계
- `summary.change_rate`: 해당 기간 vs 직전 동일 기간 비교
- `vendor_distribution`: 해당 기간 기준 분포
- `footer_summary`: 해당 기간 기준 합계
- `monthly_trend`: 변경 불필요 (기존처럼 최근 7개월 유지)
### 3. 검색 필터 (선택)
- `search` 파라미터로 거래처명/항목명 부분 검색
## 검증 데이터
현재 `monthly_trend` 기준 데이터가 있는 월:
- 11월: 14,101,865원
- 12월: 35,241,935원
- 1월: 3,000,000원
- 2월: 1,650,000원
`start_date=2026-01-01&end_date=2026-01-31` 조회 시:
- `items`: 1월 거래 내역 (현재 빈 배열)
- `summary.total_amount`: 3,000,000 (현재 0)
## 프론트엔드 준비 상태
- 프록시: 쿼리 파라미터 정상 전달 확인
- 훅: `fetchData(cardId, { startDate, endDate, search })` 지원
- 모달: 조회 버튼 + 날짜 필터 UI 완료
- 백엔드 수정만 되면 즉시 동작

View File

@@ -0,0 +1,821 @@
# CEO Dashboard 백엔드 API 명세서
**작성일**: 2026-03-03
**기획서**: SAM_ERP_Storyboard_D1.7_260227.pdf p33~60
**프론트엔드 타입**: `src/lib/api/dashboard/types.ts`
**대상**: 백엔드 팀 (Laravel sam-api)
---
## 공통 규칙
### 응답 형식
```json
{
"success": true,
"message": "조회 성공",
"data": { ... }
}
```
### 인증
- 모든 API는 `Authorization: Bearer {access_token}` 필수
- Next.js API route 프록시(`/api/proxy/...`) 경유
### 캐싱
- `sam_stat` 테이블 5분 캐시 (기존 구현 유지)
- 대시보드 API는 실시간성보다 성능 우선
### 날짜/기간 파라미터 규칙
- 날짜: `YYYY-MM-DD` (예: `2026-03-03`)
- 월: `YYYY-MM` (예: `2026-03`)
- 분기: `year=2026&quarter=1`
- 기본값: 파라미터 미지정 시 **당월/당분기** 기준
---
## 검수 중 발견된 누락 API
### N1. 오늘의 이슈 — 과거 이력 저장 및 조회
**우선순위**: 상
**페이지**: p34
**현상**: `GET /api/v1/today-issues/summary?date=2026-02-17` 호출 시 항상 `{"items":[], "total_count":0}` 반환. 과거 이슈를 저장하는 구조가 없어서 이전 이슈 탭이 항상 빈 목록.
**요구사항**:
1. **이슈 이력 테이블** 필요 (예: `dashboard_issue_history`)
- 매일 자정(또는 배치) 시점에 당일 이슈 스냅샷 저장
- 또는 이슈 발생 시점에 이력 테이블에 INSERT
2. **기존 API 수정**: `GET /api/v1/today-issues/summary`
- `date` 파라미터가 있을 때 해당 날짜의 이력 데이터 반환
- `date` 파라미터가 없으면 기존대로 실시간 집계
**Response** (기존 `TodayIssueApiResponse`와 동일):
```json
{
"items": [
{
"id": "issue-20260302-001",
"badge": "수주",
"notification_type": "sales_order",
"content": "대한건설 수주 3건 접수",
"time": "14:30",
"date": "2026-03-02",
"path": "/ko/sales/order-management",
"needs_approval": false
}
],
"total_count": 5
}
```
**Laravel 힌트**:
- 배치 저장 방식: `App\Console\Commands\SnapshotDailyIssues` (Schedule::daily)
- 또는 이벤트 기반: 수주/채권/재고 변동 시 `dashboard_issue_history` INSERT
### N2. 자금현황 — 전일 대비 변동률 (daily_change)
**우선순위**: 중
**페이지**: p33
**현상**: `GET /api/v1/daily-report/summary` 응답에 `daily_change` 필드가 없음. 프론트엔드에서 하드코딩 fallback 값(+5.2%, +2.1%, +12.0%, -8.0%)을 사용 중.
**요구사항**:
1. **기존 API 수정**: `GET /api/v1/daily-report/summary`
2. 응답에 `daily_change` 객체 추가
3. 각 항목의 전일 대비 변동률(%) 계산 로직:
- `cash_asset_change_rate`: (오늘 현금성자산 - 어제 현금성자산) / 어제 현금성자산 × 100
- `foreign_currency_change_rate`: (오늘 외국환 - 어제 외국환) / 어제 외국환 × 100
- `income_change_rate`: (오늘 입금 - 어제 입금) / 어제 입금 × 100
- `expense_change_rate`: (오늘 지출 - 어제 지출) / 어제 지출 × 100
4. 어제 데이터 없을 시 해당 필드 `null` (프론트에서 fallback 처리)
**Response** (기존 응답에 `daily_change` 추가):
```json
{
"date": "2026-03-03",
"day_of_week": "화",
"cash_asset_total": 1250000000,
"foreign_currency_total": 85000,
"krw_totals": { "income": 45000000, "expense": 32000000, "balance": 1250000000 },
"daily_change": {
"cash_asset_change_rate": 5.2,
"foreign_currency_change_rate": 2.1,
"income_change_rate": 12.0,
"expense_change_rate": -8.0
}
}
```
**Laravel 힌트**:
- `DailyReportService`에서 전일 데이터 조회 추가
- `sam_stat` 캐시 테이블에 전일 스냅샷 있으면 활용
- 프론트 타입: `DailyChangeRate` (`src/lib/api/dashboard/types.ts:23`)
### N3. 일일일보 — daily-accounts에 입출금관리 데이터 미반영
**우선순위**: 상
**페이지**: 일일일보 페이지 (`/ko/accounting/daily-report`)
**현상**: 입금관리/출금관리에서 당일 거래를 등록하면 대시보드 자금현황(`daily-report/summary`)의 합계에는 즉시 반영되지만, 일일일보 페이지의 계좌별 상세 테이블(`daily-report/daily-accounts`)에는 표시되지 않음. (출금 테스트로 확인됨, 입금도 동일 구조로 미반영 추정)
**영향 범위**:
| 데이터 | 관리 테이블 | summary (합계) | daily-accounts (상세) |
|--------|-----------|:-:|:-:|
| 입금 | `deposits` (`/api/v1/deposits`) | ✅ 반영 추정 | ❌ 미반영 추정 |
| 출금 | `withdrawals` (`/api/v1/withdrawals`) | ✅ 반영 확인 | ❌ 미반영 확인 |
| 외국환 (USD) | 별도 관리 페이지 미확인 | ✅ 반영 | ❓ 확인 필요 |
**원인 분석**:
- `GET /api/v1/daily-report/summary``krw_totals``deposits`/`withdrawals` 테이블 데이터 포함 ✅
- `GET /api/v1/daily-report/daily-accounts``bank_accounts` 단위 집계만 반환, `deposits`/`withdrawals` 테이블 미포함 ❌
**데이터 흐름**:
```
입금관리 등록 → deposits 테이블 INSERT (bank_account_id 포함)
출금관리 등록 → withdrawals 테이블 INSERT (bank_account_id 포함)
├─ summary API → krw_totals.income/expense에 합산 → 대시보드 ✅
└─ daily-accounts API → bank_accounts 기준만 조회 → 일일일보 상세 ❌
```
**요구사항**:
1. `GET /api/v1/daily-report/daily-accounts` 수정
2. 각 계좌별로 `deposits` 테이블의 당일 income과 `withdrawals` 테이블의 당일 expense를 합산
3. 또는 입금/출금 등록 시 해당 계좌의 거래 내역(`bank_account_transactions`)에도 자동 반영
**해결 방안 (택 1)**:
- **방안 A** (daily-accounts 쿼리 수정): `bank_accounts` LEFT JOIN `deposits`/`withdrawals` WHERE date = 당일 → 계좌별 income/expense에 합산
- **방안 B** (트랜잭션 연동): 입금/출금 등록 시 `bank_account_transactions`에도 INSERT → daily-accounts가 자연스럽게 포함
**Response** (기존 `DailyAccountItemApi[]`와 동일, 데이터만 보완):
```json
[
{
"id": "acc_1",
"category": "우리은행 123-456",
"match_status": "matched",
"carryover": 50000000,
"income": 1000000,
"expense": 50000,
"balance": 50950000,
"currency": "KRW"
}
]
```
**Laravel 힌트**:
- `DailyReportService``getDailyAccounts()` 메서드 확인
- `deposits` 테이블: `deposit.bank_account_id`로 해당 계좌 income 합산
- `withdrawals` 테이블: `withdrawal.bank_account_id`로 해당 계좌 expense 합산
- USD 계좌도 동일 패턴 적용 필요
### N4. 현황판 `purchases`(발주) — path 오류 + 데이터 정합성 이슈
**우선순위**: 중
**페이지**: p34 (현황판)
#### 이슈 A: path 하드코딩 오류
**현상**: `purchases` 항목의 실제 데이터는 `purchases` 테이블(매입, 공통)에서 조회하면서, path는 건설 모듈 경로로 하드코딩되어 있음.
**문제 코드** (`StatusBoardService.php``getPurchaseStatus()`):
```php
$count = Purchase::query()
->where('tenant_id', $tenantId)
->where('status', 'draft')
->count();
return [
'id' => 'purchases',
'label' => '발주',
'path' => '/construction/order/order-management', // ← 매입 데이터인데 건설 경로
];
```
- 데이터 출처: `purchases` 테이블 (모든 테넌트 공통 매입 테이블)
- path: `/construction/order/order-management` (건설 전용 페이지)
- **데이터와 path가 불일치** — 매입 draft 건수를 보여주면서 건설 발주 페이지로 링크
**현재 프론트 임시 대응**: `status-issue.ts`에서 `/accounting/purchase`(매입관리)로 오버라이드 중
**요구사항**:
1. path를 `/accounting/purchase`로 변경 (데이터 출처와 일치시키기)
2. 또는 테넌트 업종에 따라 path 동적 분기 (건설: `/construction/order/order-management`, 기타: `/accounting/purchase`)
3. 라벨도 재검토: "발주"가 맞는지, "매입(임시저장)"이 더 정확한지
#### 이슈 B: 데이터 정합성 의심
**현상**: StatusBoard API에서 `purchases` count=**9건** 반환, 하지만 매입관리 페이지(`/accounting/purchase`)에서 전체 조회 시 **1건**만 표시.
**확인 사항** (DB 직접 확인 필요):
```sql
-- 현재 테넌트의 purchases 테이블 전체 건수
SELECT COUNT(*), status FROM purchases WHERE tenant_id = {현재 테넌트 ID} GROUP BY status;
-- draft 상태 건수 (StatusBoard가 조회하는 조건)
SELECT COUNT(*) FROM purchases WHERE tenant_id = {현재 테넌트 ID} AND status = 'draft';
```
**가능한 원인**:
1. StatusBoard와 매입관리 페이지가 다른 tenant_id 스코프로 조회
2. DummyDataSeeder가 다른 tenant_id로 데이터 생성
3. 매입관리 API에 추가 필터 조건이 있어서 draft 건이 제외됨
4. StatusBoard가 실제와 다른 데이터를 집계
**기대 결과**: StatusBoard 9건 클릭 → 매입관리 페이지에서 9건 확인 가능해야 함
---
## 신규 API (10개)
### 1. 매출 현황 Summary
**우선순위**: 중
**페이지**: p39
```
GET /api/v1/dashboard/sales/summary
```
**Query Params**:
| 파라미터 | 타입 | 필수 | 설명 |
|---------|------|------|------|
| year | int | N | 조회 연도 (기본: 당해) |
| month | int | N | 조회 월 (기본: 당월) |
**Response** (`SalesStatusApiResponse`):
```json
{
"cumulative_sales": 312300000,
"achievement_rate": 94.5,
"yoy_change": 12.5,
"monthly_sales": 312300000,
"monthly_trend": [
{ "month": "2026-08", "label": "8월", "amount": 250000000 },
{ "month": "2026-09", "label": "9월", "amount": 280000000 }
],
"client_sales": [
{ "name": "대한건설", "amount": 95000000 },
{ "name": "삼성테크", "amount": 78000000 }
],
"daily_items": [
{
"date": "2026-02-01",
"client": "대한건설",
"item": "스크린 외",
"amount": 25000000,
"status": "deposited"
}
],
"daily_total": 312300000
}
```
**Laravel 힌트**:
- 매출: `sales_orders` 합계 (confirmed 상태)
- 달성률: 매출 목표 대비 (`sales_targets` 테이블)
- YoY: 전년 동월 대비 변화율
- 거래처별: GROUP BY vendor_id → TOP 5
- status 코드: `deposited` (입금완료), `unpaid` (미입금), `partial` (부분입금)
---
### 2. 매입 현황 Summary
**우선순위**: 중
**페이지**: p40
```
GET /api/v1/dashboard/purchases/summary
```
**Query Params**:
| 파라미터 | 타입 | 필수 | 설명 |
|---------|------|------|------|
| year | int | N | 조회 연도 (기본: 당해) |
| month | int | N | 조회 월 (기본: 당월) |
**Response** (`PurchaseStatusApiResponse`):
```json
{
"cumulative_purchase": 312300000,
"unpaid_amount": 312300000,
"yoy_change": -12.5,
"monthly_trend": [
{ "month": "2026-08", "label": "8월", "amount": 180000000 }
],
"material_ratio": [
{ "name": "원자재", "value": 55, "percentage": 55, "color": "#3b82f6" },
{ "name": "부자재", "value": 35, "percentage": 35, "color": "#10b981" },
{ "name": "소모품", "value": 10, "percentage": 10, "color": "#f59e0b" }
],
"daily_items": [
{
"date": "2026-02-01",
"supplier": "한국철강",
"item": "철판 외",
"amount": 45000000,
"status": "paid"
}
],
"daily_total": 312300000
}
```
**Laravel 힌트**:
- 매입: `purchase_orders` 합계
- 미결제: 결제 미완료 건 합계
- 원자재/부자재/소모품: `item_categories` 기준 분류
- status 코드: `paid` (결제완료), `unpaid` (미결제), `partial` (부분결제)
---
### 3. 생산 현황 Summary
**우선순위**: 상
**페이지**: p41
```
GET /api/v1/dashboard/production/summary
```
**Query Params**:
| 파라미터 | 타입 | 필수 | 설명 |
|---------|------|------|------|
| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) |
**Response** (`DailyProductionApiResponse`):
```json
{
"date": "2026-02-23",
"day_of_week": "월요일",
"processes": [
{
"process_name": "스크린",
"total_work": 10,
"todo": 3,
"in_progress": 4,
"completed": 3,
"urgent": 2,
"sub_line": 1,
"regular": 5,
"worker_count": 8,
"work_items": [
{
"id": "wo_1",
"order_no": "SO-2026-001",
"client": "대한건설",
"product": "스크린 A형",
"quantity": 50,
"status": "in_progress"
}
],
"workers": [
{
"name": "김철수",
"assigned": 5,
"completed": 3,
"rate": 60
}
]
}
],
"shipment": {
"expected_amount": 150000000,
"expected_count": 12,
"actual_amount": 120000000,
"actual_count": 9
}
}
```
**Laravel 힌트**:
- 공정: `work_processes` 테이블 (스크린, 슬랫, 절곡 등)
- 작업: `work_orders` JOIN `work_process_id`
- status: `pending` → todo, `in_progress`, `completed`
- urgent: 납기 3일 이내
- 출고: `shipments` 테이블 (당일 예상 vs 실적)
---
### 4. 출고 현황 (생산 현황에 포함)
**우선순위**: 하
**페이지**: p41
생산 현황 API의 `shipment` 필드로 포함됨. 별도 API 불필요.
---
### 5. 미출고 내역
**우선순위**: 하
**페이지**: p42
```
GET /api/v1/dashboard/unshipped/summary
```
**Query Params**:
| 파라미터 | 타입 | 필수 | 설명 |
|---------|------|------|------|
| days | int | N | 납기 N일 이내 (기본: 30) |
**Response** (`UnshippedApiResponse`):
```json
{
"items": [
{
"id": "us_1",
"port_no": "P-2026-001",
"site_name": "강남 현장",
"order_client": "대한건설",
"due_date": "2026-02-25",
"days_left": 2
}
],
"total_count": 7
}
```
**Laravel 힌트**:
- `shipment_items` WHERE shipped_at IS NULL AND due_date >= NOW()
- days_left: DATEDIFF(due_date, NOW())
- ORDER BY due_date ASC (납기 임박 순)
---
### 6. 시공 현황
**우선순위**: 중
**페이지**: p42
```
GET /api/v1/dashboard/construction/summary
```
**Query Params**:
| 파라미터 | 타입 | 필수 | 설명 |
|---------|------|------|------|
| month | int | N | 조회 월 (기본: 당월) |
**Response** (`ConstructionApiResponse`):
```json
{
"this_month": 15,
"completed": 5,
"items": [
{
"id": "cs_1",
"site_name": "강남 현장",
"client": "대한건설",
"start_date": "2026-02-01",
"end_date": "2026-02-28",
"progress": 85,
"status": "in_progress"
}
]
}
```
**Laravel 힌트**:
- `constructions` 테이블
- status: `in_progress`, `scheduled`, `completed`
- completed: 최근 7일 이내 완료 건
---
### 7. 근태 현황
**우선순위**: 중
**페이지**: p43
```
GET /api/v1/dashboard/attendance/summary
```
**Query Params**:
| 파라미터 | 타입 | 필수 | 설명 |
|---------|------|------|------|
| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) |
**Response** (`DailyAttendanceApiResponse`):
```json
{
"present": 42,
"on_leave": 3,
"late": 1,
"absent": 0,
"employees": [
{
"id": "emp_1",
"department": "생산부",
"position": "과장",
"name": "김철수",
"status": "present"
}
]
}
```
**Laravel 힌트**:
- `attendances` WHERE date = :date
- status: `present`, `on_leave`, `late`, `absent`
- employees: 이상 상태(late, absent, on_leave) 위주 표시
---
### 8. 일별 매출 내역
**우선순위**: 하
**페이지**: p47 (설정 팝업에서 별도 ON/OFF)
매출 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시:
```
GET /api/v1/dashboard/sales/daily
```
**Query Params**:
| 파라미터 | 타입 | 필수 | 설명 |
|---------|------|------|------|
| start_date | string | N | 시작일 (기본: 당월 1일) |
| end_date | string | N | 종료일 (기본: 오늘) |
| page | int | N | 페이지 (기본: 1) |
| per_page | int | N | 건수 (기본: 20) |
---
### 9. 일별 매입 내역
**우선순위**: 하
매입 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시:
```
GET /api/v1/dashboard/purchases/daily
```
(매출 일별과 동일 구조)
---
### 10. 접대비 상세
**우선순위**: 상
**페이지**: p53-54
```
GET /api/v1/dashboard/entertainment/detail
```
**Query Params**:
| 파라미터 | 타입 | 필수 | 설명 |
|---------|------|------|------|
| year | int | N | 연도 |
| quarter | int | N | 분기 (1-4) |
| limit_type | string | N | annual/quarterly |
| company_type | string | N | large/medium/small |
**Response**:
```json
{
"summary": {
"total_used": 10000000,
"annual_limit": 40120000,
"remaining": 30120000,
"usage_rate": 24.9
},
"limit_calculation": {
"base_limit": 36000000,
"revenue_additional": 4120000,
"total_limit": 40120000,
"revenue": 2060000000,
"company_type": "medium"
},
"quarterly_status": [
{
"quarter": 1,
"label": "1분기",
"limit": 10030000,
"used": 3500000,
"remaining": 6530000,
"exceeded": 0
}
],
"transactions": [
{
"id": 1,
"date": "2026-01-15",
"user_name": "홍길동",
"merchant_name": "강남식당",
"amount": 350000,
"counterpart": "대한건설",
"receipt_type": "법인카드",
"risk_flags": ["high_amount"]
}
]
}
```
---
## 수정 API (6개)
### 1. 가지급금 Summary (수정)
**현재**: 카드/가지급금/법인세/종합세
**변경**: 카드/경조사/상품권/접대비/총합계 (5카드)
```
GET /api/proxy/card-transactions/summary
```
**Response 변경**:
```json
{
"cards": [
{ "id": "cm1", "label": "카드", "amount": 3123000, "sub_label": "미정리 5건", "count": 5 },
{ "id": "cm2", "label": "경조사", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 },
{ "id": "cm3", "label": "상품권", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 },
{ "id": "cm4", "label": "접대비", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 },
{ "id": "cm_total", "label": "총 가지급금 합계", "amount": 350000000 }
],
"check_points": [
{
"id": "cm-cp1",
"type": "warning",
"message": "법인카드 사용 총 850만원이 가지급금으로 전환되었습니다.",
"highlights": [{ "text": "850만원", "color": "red" }]
}
],
"warning_banner": "가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의"
}
```
**Laravel 힌트**:
- 분류: `card_transactions.category` 기준 (card/congratulation/gift_card/entertainment)
- 미정리/미증빙: `evidence_status = 'pending'` COUNT
---
### 2. 접대비 Summary (수정)
**현재**: 매출/한도/잔여한도/사용금액
**변경**: 주말심야/기피업종/고액결제/증빙미비 (리스크 4종)
```
GET /api/proxy/entertainment/summary
```
**Response 변경**:
```json
{
"cards": [
{ "id": "et1", "label": "주말/심야", "amount": 3123000, "sub_label": "5건", "count": 5 },
{ "id": "et2", "label": "기피업종 (유흥, 귀금속 등)", "amount": 3123000, "sub_label": "불인정 5건", "count": 5 },
{ "id": "et3", "label": "고액 결제", "amount": 3123000, "sub_label": "5건", "count": 5 },
{ "id": "et4", "label": "증빙 미비", "amount": 3123000, "sub_label": "5건", "count": 5 }
],
"check_points": [...]
}
```
**리스크 감지 로직** (p60 참조):
- 주말/심야: 토~일, 22:00~06:00 거래
- 기피업종: MCC 코드 기반 (유흥업소 7273, 귀금속 5944, 골프장 7941 등)
- 고액 결제: 설정 금액(기본 50만원) 초과
- 증빙 미비: 적격증빙(세금계산서/카드매출전표) 없는 건
---
### 3. 복리후생비 Summary (수정)
**현재**: 한도/잔여한도/사용금액
**변경**: 비과세한도초과/사적사용의심/특정인편중/항목별한도초과 (리스크 4종)
```
GET /api/proxy/welfare/summary
```
**Response 변경**:
```json
{
"cards": [
{ "id": "wf1", "label": "비과세 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 },
{ "id": "wf2", "label": "사적 사용 의심", "amount": 3123000, "sub_label": "5건", "count": 5 },
{ "id": "wf3", "label": "특정인 편중", "amount": 3123000, "sub_label": "5건", "count": 5 },
{ "id": "wf4", "label": "항목별 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 }
],
"check_points": [...]
}
```
**리스크 감지 로직**:
- 비과세 한도 초과: 항목별 비과세 기준 초과 (식대 20만원, 교통비 10만원 등)
- 사적 사용 의심: 주말/야간 + 비업무 업종 조합
- 특정인 편중: 직원별 사용액 편차 > 평균의 200%
- 항목별 한도 초과: 설정 금액 초과
---
### 4. 가지급금 Detail (수정)
기존 `LoanDashboardApiResponse`에 AI분류 컬럼 추가.
```
GET /api/v1/loans/dashboard
```
**Response 추가 필드**:
```json
{
"items": [
{
"...기존 필드...",
"ai_category": "카드",
"evidence_status": "미증빙"
}
]
}
```
---
### 5. 복리후생비 Detail (수정)
기존 `WelfareDetailApiResponse`에 계산방식 파라미터 추가.
```
GET /api/proxy/welfare/detail?calculation_type=fixed&fixed_amount_per_month=200000
```
(기존 구현 유지, 계산 파라미터만 반영 확인)
---
### 6. 부가세 Detail (수정)
기존 `VatApiResponse`에 신고기간 파라미터 반영.
```
GET /api/proxy/vat/summary?period_type=quarter&year=2026&period=1
```
(기존 구현 유지, 기간별 필터링 확인)
---
## 리스크 감지 로직 참고 (p58-60)
### MCC 코드 기피업종
| MCC | 업종 | 분류 |
|-----|------|------|
| 7273 | 유흥업소 | 기피업종 |
| 5944 | 귀금속 | 기피업종 |
| 7941 | 골프장 | 기피업종 |
| 5813 | 주점 | 기피업종 |
| 7011 | 호텔/리조트 | 주의업종 |
### 리스크 판별 규칙
```
규칙1: 시간대 이상 → 22:00~06:00 또는 토~일
규칙2: 업종 이상 → MCC 기피업종 해당
규칙3: 금액 이상 → 설정 금액 초과 (기본 50만원)
규칙4: 빈도 이상 → 월 10회 이상 동일 업종
규칙5: 증빙 미비 → 적격증빙 없음
리스크 등급:
- 2개 이상 해당 → 🔴 고위험
- 1개 해당 → 🟡 주의
- 0개 → 🟢 정상
```
---
## 계산 공식 참고
### 가지급금 인정이자 (p58)
```
인정이자 = 가지급금잔액 × (4.6% / 365) × 경과일수
법인세 추가 = 인정이자 × 19%
대표자 소득세 = 인정이자 × 35%
```
### 접대비 손금한도 (p59)
```
기본한도:
일반법인: 1,200만원/년
중소기업: 3,600만원/년
수입금액별 추가:
100억 이하: 수입금액 × 0.2%
100~500억: 2,000만원 + (수입금액-100억) × 0.1%
500억 초과: 6,000만원 + (수입금액-500억) × 0.03%
```
### 복리후생비 (p60)
```
방식1 (정액): 직원수 × 월정액 × 12
방식2 (비율): 연봉총액 × 비율%
비과세 한도:
식대: 20만원/월
교통비: 10만원/월
경조사: 5만원/건
건강검진: 연간 총액/12 환산
교육훈련: 8만원/월
복지포인트: 10만원/월
```
---
## 우선순위 정리
| 우선순위 | API | 이유 |
|---------|-----|------|
| 🔴 상 | 접대비 summary 수정, 복리후생비 summary 수정 | D1.7 카드 구조 변경 |
| 🔴 상 | 가지급금 summary 수정 | D1.7 카드 구조 변경 |
| 🔴 상 | 접대비 detail 신규 | 모달 확장 |
| 🟡 중 | 매출 현황, 매입 현황, 시공 현황, 근태 현황 | 신규 섹션 |
| 🟡 중 | 생산 현황 | 복잡한 공정 집계 |
| 🟢 하 | 미출고 내역, 일별 매출/매입 | 단순 조회 |

BIN
claudedocs/architecture/.DS_Store vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,176 @@
# CEO Dashboard 분석 (기획서 D1.7 기준)
**기획서**: `SAM_ERP_Storyboard_D1.7_260227.pdf` p33~60
**분석일**: 2026-02-27
**상태**: 기획서 분석 완료, 구현 대기
---
## 1. 전체 구성
| 구분 | 페이지 | 수량 |
|------|--------|------|
| 메인 대시보드 섹션 | p33~43 | 20개 |
| 상세 모달 | p44~57 | 10개 |
| 참고 자료 (계산공식) | p58~60 | 3페이지 |
---
## 2. 섹션별 현황 (20개)
### API 연동 완료 (11개)
| # | 섹션 | 페이지 | hook | API endpoint |
|---|------|--------|------|-------------|
| 1 | 오늘의 이슈 | p33 | useTodayIssue | today-issues/summary |
| 2 | 자금 현황 | p33-34 | useCEODashboard | daily-report/summary |
| 3 | 현황판 | p34 | useStatusBoard | status-board/summary |
| 4 | 당월 예상 지출 | p34-35 | useMonthlyExpense | expected-expenses/summary |
| 5 | 가지급금 현황 | p35 | useCardManagement | card-transactions/summary + 2개 |
| 6 | 접대비 현황 | p35-36 | useEntertainment | entertainment/summary |
| 7 | 복리후생비 현황 | p36 | useWelfare | welfare/summary |
| 8 | 미수금 현황 | p36 | useReceivable | receivables/summary |
| 9 | 채권추심 현황 | p37 | useDebtCollection | bad-debts/summary |
| 10 | 부가세 현황 | p37-38 | useVat | vat/summary |
| 11 | 캘린더 | p38 | useCalendar | calendar/schedules |
### Mock 데이터만 (9개) - API 신규 필요
| # | 섹션 | 페이지 | 필요 데이터 |
|---|------|--------|-----------|
| 12 | 매출 현황 | p39 | 누적매출, 달성률, YoY, 당월매출 + 차트2 + 테이블 |
| 13 | 일별 매출 내역 | p47(설정) | 매출일, 거래처, 매출금액 (🆕 신규 섹션) |
| 14 | 매입 현황 | p40 | 누적매입, 미결제, YoY + 차트2 + 테이블 |
| 15 | 일별 매입 내역 | p47(설정) | 매입일, 거래처, 매입금액 (🆕 신규 섹션) |
| 16 | 생산 현황 | p41 | 공정별(스크린/슬랫/절곡) 집계 + 작업자현황 |
| 17 | 출고 현황 | p41 | 예상출고 7일/30일 금액+건수 |
| 18 | 미출고 내역 | p42 | 로트번호, 현장명, 수주처, 잔량, 납기일 |
| 19 | 시공 현황 | p42 | 진행/완료(7일이내) + 현장카드 |
| 20 | 근태 현황 | p43 | 출근/휴가/지각/결근 + 직원테이블 |
---
## 3. 🔴 D1.7 핵심 변경사항
### 카드 구조 변경 (한도관리형 → 리스크감지형)
| 섹션 | 기존 구현 | D1.7 기획서 |
|------|---------|-----------|
| **가지급금** | 카드, 가지급금, 법인세예상, 종합세예상 | 카드, 경조사, 상품권, 접대비, 총합계 (5카드) |
| **접대비** | 매출, 분기한도, 잔여한도, 사용금액 | **주말/심야, 기피업종, 고액결제, 증빙미비** |
| **복리후생비** | 당해한도, 분기한도, 잔여한도, 사용금액 | **비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과** |
### 신규 섹션 (2개)
- 일별 매출 내역: 항목 설정(p47)에서 별도 ON/OFF
- 일별 매입 내역: 항목 설정(p47)에서 별도 ON/OFF
### 설정 팝업 확장 (p45-47)
- 접대비: 한도관리(연간/반기/분기/월), 기업구분(일반법인/중소기업), 고액결제기준금액
- 복리후생비: 한도관리, 계산방식(직원당정액 or 연봉총액×비율), 조건부입력필드, 1회결제기준금액
---
## 4. 상세 모달 (10개)
| # | 모달 | 페이지 | 프론트 config | API 상태 |
|---|------|--------|-------------|---------|
| 1 | 일정 상세 | p44 | ✅ ScheduleDetailModal | ✅ 연동 |
| 2 | 항목 설정 | p45-47 | ✅ DashboardSettingsDialog | localStorage |
| 3 | 당월 매입 상세 | p48 | ✅ me1 config | ⚠️ 부분연동 |
| 4 | 당월 카드 상세 | p49 | ✅ me2 config | ⚠️ 부분연동 |
| 5 | 당월 발행어음 상세 | p50 | ✅ me3 config | ⚠️ 부분연동 |
| 6 | 당월 지출 예상 상세 | p51 | ✅ me4 config | ⚠️ 부분연동 |
| 7 | 가지급금 상세 | p52 | ✅ cm2 config | ⚠️ 구조변경 필요 |
| 8 | 접대비 상세 | p53-54 | ✅ et config | ⚠️ 대폭확장 |
| 9 | 복리후생비 상세 | p55-56 | ✅ wf config | ⚠️ 대폭확장 |
| 10 | 예상 납부세액 상세 | p57 | ✅ vat config | ⚠️ 확장필요 |
---
## 5. 필요 API 작업 (16개)
### 백엔드 API 수정 (6개)
| # | API | 변경 내용 |
|---|-----|---------|
| 1 | 가지급금 summary | 카드/경조사/상품권/접대비 분류 집계 |
| 2 | 접대비 summary | 리스크 4종 (주말심야/기피업종/고액/증빙미비) - MCC코드 판별 |
| 3 | 복리후생비 summary | 리스크 4종 (비과세초과/사적사용/편중/한도초과) |
| 4 | 가지급금 detail | 분류별 상세 + AI분류 컬럼 |
| 5 | 복리후생비 detail | 계산방식별 + 분기별현황 |
| 6 | 부가세 detail | 신고기간별 + 부가세요약 + 미발행/미수취 |
### 백엔드 API 신규 (10개)
| # | API | 용도 | 난이도 |
|---|-----|------|--------|
| 1 | 접대비 detail | 한도계산 + 분기별현황 + 내역테이블 | 상 |
| 2 | 매출 현황 summary | 누적/달성률/YoY/당월 + 차트 | 중 |
| 3 | 일별 매출 내역 | 매출일, 거래처, 매출금액 | 하 |
| 4 | 매입 현황 summary | 누적/미결제/YoY + 차트 | 중 |
| 5 | 일별 매입 내역 | 매입일, 거래처, 매입금액 | 하 |
| 6 | 생산 현황 | 공정별 집계 + 작업자실적 | 상 |
| 7 | 출고 현황 | 7일/30일 예상출고 | 하 |
| 8 | 미출고 내역 | 납기기준 미출고 조회 | 하 |
| 9 | 시공 현황 | 진행/완료(7일이내) + 카드 | 중 |
| 10 | 근태 현황 | 출근/휴가/지각/결근 집계 | 중 |
---
## 6. 프론트엔드 작업 (8개)
| # | 작업 | 대상 |
|---|------|------|
| 1 | 가지급금 카드 구조 변경 | CardManagementSection |
| 2 | 접대비 카드 → 리스크형 | EntertainmentSection |
| 3 | 복리후생비 카드 → 리스크형 | WelfareSection |
| 4 | 일별 매출 내역 섹션 신규 | 새 컴포넌트 |
| 5 | 일별 매입 내역 섹션 신규 | 새 컴포넌트 |
| 6 | 항목 설정 팝업 업데이트 | DashboardSettingsDialog |
| 7 | 모달 config API 연동 | 각 modalConfigs |
| 8 | Mock 섹션 API 연동 | 매출~근태 hook 생성 |
---
## 7. 데이터 아키텍처
대시보드 전용 테이블 없음. 모든 데이터는 각 도메인 페이지 입력 데이터의 실시간 집계.
### 자금 현황 데이터 조합
| 카드 | 출처 |
|------|------|
| 일일일보 | bank_accounts 잔액 합계 |
| 미수금 잔액 | sales 합계 - deposits 합계 |
| 미지급금 잔액 | purchases 합계 - payments 합계 |
| 당월 예상 지출 | 매입예정 + 카드결제 + 어음만기 합산 |
### 리스크 감지 로직 (접대비/복리후생비)
- MCC 코드 기반 업종 판별 (p60: 유흥업소, 귀금속, 골프장 등)
- 체크 규칙: 시간대이상(22~06시), 업종이상, 금액이상(50만원), 빈도이상(월10회)
- 사적사용 의심: 토요일 23시 + 유흥주점 + 25만원 → 2개 규칙 해당
### 캐싱
- sam_stat 테이블 5분 캐시 (백엔드 기존 구현)
---
## 8. 참고 계산 공식 (p58-60)
### 가지급금 인정이자
- 인정이자율: 4.6% (당좌대출이자율 기준, 매년 고시)
- 인정이자 = 가지급금 × 일이자율(연이자율/365) × 경과일수
- 법인세 추가: 인정이자 × 0.19
- 대표자 소득세 추가: 인정이자 × 0.35
### 접대비 손금한도
- 기본한도: 일반법인 1,200만원/년, 중소기업 3,600만원/년
- 수입금액별 추가한도:
- 100억 이하: 수입금액 × 0.2%
- 100억~500억: 2,000만원 + (수입금액-100억) × 0.1%
- 500억 초과: 6,000만원 + (수입금액-500억) × 0.03%
### 복리후생비 계산
- 방식1 (직원당 정액): 직원수 × 월정액 × 12
- 방식2 (연봉총액 비율): 연봉총액 × 비율%
- 법정 복리후생비: 4대보험 회사부담분
- 비과세 항목별 기준: 식대 20만원, 교통비 10만원, 경조사 5만원, 건강검진 월환산, 교육훈련 8만원, 복지포인트 10만원

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbo",
"dev": "next dev",
"build": "next build",
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
"start": "next start -H 0.0.0.0",

View File

@@ -55,7 +55,7 @@ export default function BadDebtCollectionPage() {
return (
<BadDebtCollection
initialData={data}
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; recovered_amount: number; bad_debt_amount: number; } | null}
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; collection_end_amount: number; } | null}
/>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -96,6 +96,14 @@ import { DataTable } from '@/components/organisms/DataTable';
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal';
// UI - 추가
import { VisuallyHidden } from '@/components/ui/visually-hidden';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import { DateTimePicker } from '@/components/ui/date-time-picker';
// Molecules - 추가
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { GenericCRUDDialog } from '@/components/molecules/GenericCRUDDialog';
import { ReorderButtons } from '@/components/molecules/ReorderButtons';
// Organisms - 추가
import { LineItemsTable } from '@/components/organisms/LineItemsTable/LineItemsTable';
// Lucide icons for demos
import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react';
@@ -339,6 +347,89 @@ function SearchableSelectionDemo() {
);
}
// ── 추가 Demo Wrappers ──
function DateRangePickerDemo() {
const [start, setStart] = useState<string | undefined>();
const [end, setEnd] = useState<string | undefined>();
return (
<div className="max-w-sm">
<DateRangePicker startDate={start} endDate={end} onStartDateChange={setStart} onEndDateChange={setEnd} />
</div>
);
}
function DateTimePickerDemo() {
const [v, setV] = useState<string | undefined>();
return (
<div className="max-w-sm">
<DateTimePicker value={v} onChange={setV} />
</div>
);
}
function ColumnSettingsPopoverDemo() {
const [cols, setCols] = useState([
{ key: 'name', label: '품목명', visible: true, locked: true },
{ key: 'spec', label: '규격', visible: true, locked: false },
{ key: 'qty', label: '수량', visible: true, locked: false },
{ key: 'price', label: '단가', visible: false, locked: false },
{ key: 'note', label: '비고', visible: false, locked: false },
]);
return (
<ColumnSettingsPopover
columns={cols}
onToggle={(key) => setCols((prev) => prev.map((c) => (c.key === key && !c.locked ? { ...c, visible: !c.visible } : c)))}
onReset={() => setCols((prev) => prev.map((c) => ({ ...c, visible: true })))}
hasHiddenColumns={cols.some((c) => !c.visible)}
/>
);
}
function GenericCRUDDialogDemo() {
const [open, setOpen] = useState(false);
return (
<>
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>CRUD </Button>
<GenericCRUDDialog
isOpen={open}
onOpenChange={setOpen}
mode="add"
entityName="직급"
fields={[
{ key: 'name', label: '직급명', type: 'text', placeholder: '직급명 입력' },
{ key: 'status', label: '상태', type: 'select', options: [{ value: 'active', label: '활성' }, { value: 'inactive', label: '비활성' }], defaultValue: 'active' },
]}
onSubmit={() => setOpen(false)}
/>
</>
);
}
function LineItemsTableDemo() {
const [items, setItems] = useState([
{ id: '1', itemName: '볼트 M10x30', quantity: 100, unitPrice: 500, supplyAmount: 50000, vat: 5000, note: '' },
{ id: '2', itemName: '너트 M10', quantity: 200, unitPrice: 300, supplyAmount: 60000, vat: 6000, note: '' },
]);
return (
<div className="max-w-3xl overflow-x-auto">
<LineItemsTable
items={items}
getItemName={(i) => i.itemName}
getQuantity={(i) => i.quantity}
getUnitPrice={(i) => i.unitPrice}
getSupplyAmount={(i) => i.supplyAmount}
getVat={(i) => i.vat}
getNote={(i) => i.note}
onItemChange={(idx, field, value) => setItems((prev) => prev.map((item, i) => (i === idx ? { ...item, [field]: value } : item)))}
onAddItem={() => setItems((prev) => [...prev, { id: String(prev.length + 1), itemName: '', quantity: 1, unitPrice: 0, supplyAmount: 0, vat: 0, note: '' }])}
onRemoveItem={(idx) => setItems((prev) => prev.filter((_, i) => i !== idx))}
totals={{ supplyAmount: items.reduce((s, i) => s + i.supplyAmount, 0), vat: items.reduce((s, i) => s + i.vat, 0), total: items.reduce((s, i) => s + i.supplyAmount + i.vat, 0) }}
/>
</div>
);
}
// ── Preview Registry ──
type PreviewEntry = {
@@ -937,6 +1028,14 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
},
],
'date-range-picker.tsx': [
{ label: 'DateRangePicker', render: () => <DateRangePickerDemo /> },
],
'date-time-picker.tsx': [
{ label: 'DateTimePicker', render: () => <DateTimePickerDemo /> },
],
// ─── Atoms ───
'BadgeSm.tsx': [
{
@@ -1184,6 +1283,36 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
{ label: 'Filter', render: () => <MobileFilterDemo /> },
],
'ColumnSettingsPopover.tsx': [
{ label: 'Popover', render: () => <ColumnSettingsPopoverDemo /> },
],
'GenericCRUDDialog.tsx': [
{ label: 'CRUD Dialog', render: () => <GenericCRUDDialogDemo /> },
],
'ReorderButtons.tsx': [
{
label: 'Sizes',
render: () => (
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">sm:</span>
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="sm" />
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">xs:</span>
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="xs" />
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">disabled:</span>
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={true} isLast={true} size="sm" />
</div>
</div>
),
},
],
// ─── Organisms ───
'EmptyState.tsx': [
{
@@ -1440,4 +1569,8 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
'SearchableSelectionModal.tsx': [
{ label: 'Modal', render: () => <SearchableSelectionDemo /> },
],
'LineItemsTable.tsx': [
{ label: 'Line Items', render: () => <LineItemsTableDemo /> },
],
};

View File

@@ -6,6 +6,7 @@ import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/ty
export const MOCK_WORK_ORDER: WorkOrder = {
id: 'wo-1',
orderNo: 'KD-WO-240924-01',
productCode: 'WY-SC780',
productName: '스크린 셔터 (표준형)',
processCode: 'screen',
processName: 'screen',

View File

@@ -30,6 +30,7 @@ import {
Circle,
Activity,
Play,
ChevronDown,
} from "lucide-react";
import { PageLayout } from "@/components/organisms/PageLayout";
import { PageHeader } from "@/components/organisms/PageHeader";
@@ -47,143 +48,17 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { toast } from "sonner";
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
import { formatNumber } from '@/lib/utils/amount';
// 생산지시 상태 타입
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
// 작업지시 상태 타입
type WorkOrderStatus = "pending" | "in_progress" | "completed";
// 작업지시 데이터 타입
interface WorkOrder {
id: string;
workOrderNumber: string; // KD-WO-XXXXXX-XX
process: string; // 공정명
quantity: number;
status: WorkOrderStatus;
assignee: string;
}
// 생산지시 상세 데이터 타입
interface ProductionOrderDetail {
id: string;
productionOrderNumber: string;
orderNumber: string;
productionOrderDate: string;
dueDate: string;
quantity: number;
status: ProductionOrderStatus;
client: string;
siteName: string;
productType: string;
pendingWorkOrderCount: number; // 생성 예정 작업지시 수
workOrders: WorkOrder[];
}
// 샘플 생산지시 상세 데이터
const SAMPLE_PRODUCTION_ORDER_DETAILS: Record<string, ProductionOrderDetail> = {
"PO-001": {
id: "PO-001",
productionOrderNumber: "PO-KD-TS-251217-07",
orderNumber: "KD-TS-251217-07",
productionOrderDate: "2025-12-22",
dueDate: "2026-02-15",
quantity: 2,
status: "completed", // 생산완료 상태 - 목록 버튼만 표시
client: "호반건설(주)",
siteName: "씨밋 광교 센트럴시티",
productType: "",
pendingWorkOrderCount: 0, // 작업지시 이미 생성됨
workOrders: [
{
id: "WO-001",
workOrderNumber: "KD-WO-251217-07",
process: "재단",
quantity: 2,
status: "completed",
assignee: "-",
},
{
id: "WO-002",
workOrderNumber: "KD-WO-251217-08",
process: "조립",
quantity: 2,
status: "completed",
assignee: "-",
},
{
id: "WO-003",
workOrderNumber: "KD-WO-251217-09",
process: "검수",
quantity: 2,
status: "completed",
assignee: "-",
},
],
},
"PO-002": {
id: "PO-002",
productionOrderNumber: "PO-KD-TS-251217-09",
orderNumber: "KD-TS-251217-09",
productionOrderDate: "2025-12-22",
dueDate: "2026-02-10",
quantity: 10,
status: "waiting",
client: "태영건설(주)",
siteName: "데시앙 동탄 파크뷰",
productType: "",
pendingWorkOrderCount: 5, // 생성 예정 작업지시 5개 (일괄생성)
workOrders: [],
},
"PO-003": {
id: "PO-003",
productionOrderNumber: "PO-KD-TS-251217-06",
orderNumber: "KD-TS-251217-06",
productionOrderDate: "2025-12-22",
dueDate: "2026-02-10",
quantity: 1,
status: "waiting",
client: "롯데건설(주)",
siteName: "예술 검실 푸르지오",
productType: "",
pendingWorkOrderCount: 1, // 생성 예정 작업지시 1개 (단일 생성)
workOrders: [],
},
"PO-004": {
id: "PO-004",
productionOrderNumber: "PO-KD-BD-251220-35",
orderNumber: "KD-BD-251220-35",
productionOrderDate: "2025-12-20",
dueDate: "2026-02-03",
quantity: 3,
status: "in_progress",
client: "현대건설(주)",
siteName: "[코레타스프] 판교 물류센터 철거현장",
productType: "",
pendingWorkOrderCount: 0,
workOrders: [
{
id: "WO-004",
workOrderNumber: "KD-WO-251220-01",
process: "재단",
quantity: 3,
status: "completed",
assignee: "-",
},
{
id: "WO-005",
workOrderNumber: "KD-WO-251220-02",
process: "조립",
quantity: 3,
status: "in_progress",
assignee: "-",
},
],
},
};
import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions";
import { createProductionOrder } from "@/components/orders/actions";
import type {
ProductionOrderDetail,
ProductionStatus,
ProductionWorkOrder,
BomProcessGroup,
} from "@/components/production/ProductionOrders/types";
// 공정 진행 현황 컴포넌트
function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] }) {
if (workOrders.length === 0) {
return (
<Card>
@@ -202,7 +77,9 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
);
}
const completedCount = workOrders.filter((w) => w.status === "completed").length;
const completedCount = workOrders.filter(
(w) => w.status === "completed" || w.status === "shipped"
).length;
const totalCount = workOrders.length;
const progressPercent = Math.round((completedCount / totalCount) * 100);
@@ -237,25 +114,27 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
<div className="flex flex-col items-center gap-1">
<div
className={`flex items-center justify-center w-8 h-8 rounded-full ${
wo.status === "completed"
wo.status === "completed" || wo.status === "shipped"
? "bg-green-500 text-white"
: wo.status === "in_progress"
? "bg-blue-500 text-white"
: "bg-gray-100 text-gray-400"
}`}
>
{wo.status === "completed" ? (
{wo.status === "completed" || wo.status === "shipped" ? (
<CheckCircle2 className="h-4 w-4" />
) : (
<Circle className="h-4 w-4" />
)}
</div>
<span className="text-xs text-muted-foreground">{wo.process}</span>
<span className="text-xs text-muted-foreground">{wo.processName}</span>
</div>
{index < workOrders.length - 1 && (
<div
className={`w-12 h-0.5 mx-1 ${
wo.status === "completed" ? "bg-green-500" : "bg-gray-200"
wo.status === "completed" || wo.status === "shipped"
? "bg-green-500"
: "bg-gray-200"
}`}
/>
)}
@@ -269,13 +148,13 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
}
// 상태 배지 헬퍼
function getStatusBadge(status: ProductionOrderStatus) {
const config: Record<ProductionOrderStatus, { label: string; className: string }> = {
function getStatusBadge(status: ProductionStatus) {
const config: Record<ProductionStatus, { label: string; className: string }> = {
waiting: {
label: "생산대기",
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
},
in_progress: {
in_production: {
label: "생산중",
className: "bg-green-100 text-green-700 border-green-200",
},
@@ -289,22 +168,16 @@ function getStatusBadge(status: ProductionOrderStatus) {
}
// 작업지시 상태 배지 헬퍼
function getWorkOrderStatusBadge(status: WorkOrderStatus) {
const config: Record<WorkOrderStatus, { label: string; className: string }> = {
pending: {
label: "대기",
className: "bg-gray-100 text-gray-700 border-gray-200",
},
in_progress: {
label: "작업중",
className: "bg-blue-100 text-blue-700 border-blue-200",
},
completed: {
label: "완료",
className: "bg-green-100 text-green-700 border-green-200",
},
function getWorkOrderStatusBadge(status: string) {
const config: Record<string, { label: string; className: string }> = {
unassigned: { label: "미배정", className: "bg-gray-100 text-gray-700 border-gray-200" },
pending: { label: "대기", className: "bg-gray-100 text-gray-700 border-gray-200" },
waiting: { label: "준비중", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
in_progress: { label: "작업중", className: "bg-blue-100 text-blue-700 border-blue-200" },
completed: { label: "완료", className: "bg-green-100 text-green-700 border-green-200" },
shipped: { label: "출하", className: "bg-purple-100 text-purple-700 border-purple-200" },
};
const c = config[status];
const c = config[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
}
@@ -318,99 +191,33 @@ function InfoItem({ label, value }: { label: string; value: string }) {
);
}
// 샘플 공정 목록 (작업지시 생성 팝업에 표시용)
const SAMPLE_PROCESSES = [
{ id: "P1", name: "1.1 백판필름", quantity: 10 },
{ id: "P2", name: "2. 하안마감재", quantity: 10 },
{ id: "P3", name: "3.1 케이스", quantity: 10 },
{ id: "P4", name: "4. 연기단자", quantity: 10 },
{ id: "P5", name: "5. 가이드레일 하부브라켓", quantity: 10 },
];
// BOM 품목 타입
interface BomItem {
id: string;
itemCode: string;
itemName: string;
spec: string;
lotNo: string;
requiredQty: number;
qty: number;
}
// BOM 공정 분류 타입
interface BomProcessGroup {
processName: string;
sizeSpec?: string;
items: BomItem[];
}
// BOM 품목별 공정 분류 목데이터
const SAMPLE_BOM_PROCESS_GROUPS: BomProcessGroup[] = [
{
processName: "1.1 백판필름",
sizeSpec: "[20-70]",
items: [
{ id: "B1", itemCode: "①", itemName: "아연판", spec: "EGI.6T", lotNo: "LOT-M-2024-001", requiredQty: 4300, qty: 8 },
{ id: "B2", itemCode: "②", itemName: "가이드레일", spec: "EGI.6T", lotNo: "LOT-M-2024-002", requiredQty: 3000, qty: 4 },
{ id: "B3", itemCode: "③", itemName: "C형", spec: "EGI.6ST", lotNo: "LOT-M-2024-003", requiredQty: 4300, qty: 4 },
{ id: "B4", itemCode: "④", itemName: "D형", spec: "EGI.6T", lotNo: "LOT-M-2024-004", requiredQty: 4300, qty: 4 },
{ id: "B5", itemCode: "⑤", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-005", requiredQty: 4300, qty: 2 },
{ id: "B6", itemCode: "⑥", itemName: "R/RBASE", spec: "EGI.5ST", lotNo: "LOT-M-2024-006", requiredQty: 0, qty: 4 },
],
},
{
processName: "2. 하안마감재",
sizeSpec: "[60-40]",
items: [
{ id: "B7", itemCode: "①", itemName: "하안마감재", spec: "EGI.5T", lotNo: "LOT-M-2024-007", requiredQty: 3000, qty: 4 },
{ id: "B8", itemCode: "②", itemName: "하안보강재판", spec: "EGI.5T", lotNo: "LOT-M-2024-009", requiredQty: 3000, qty: 4 },
{ id: "B9", itemCode: "③", itemName: "하안보강멈틀", spec: "EGI.1T", lotNo: "LOT-M-2024-010", requiredQty: 0, qty: 2 },
{ id: "B10", itemCode: "④", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-008", requiredQty: 3000, qty: 2 },
],
},
{
processName: "3.1 케이스",
sizeSpec: "[500*330]",
items: [
{ id: "B11", itemCode: "①", itemName: "전판", spec: "EGI.5ST", lotNo: "LOT-M-2024-011", requiredQty: 0, qty: 2 },
{ id: "B12", itemCode: "②", itemName: "전멈틀", spec: "EGI.5T", lotNo: "LOT-M-2024-012", requiredQty: 0, qty: 4 },
{ id: "B13", itemCode: "③⑤", itemName: "타부멈틀", spec: "", lotNo: "LOT-M-2024-013", requiredQty: 0, qty: 2 },
{ id: "B14", itemCode: "④", itemName: "좌우판-HW", spec: "", lotNo: "LOT-M-2024-014", requiredQty: 0, qty: 2 },
{ id: "B15", itemCode: "⑥", itemName: "상부판", spec: "", lotNo: "LOT-M-2024-015", requiredQty: 1220, qty: 5 },
{ id: "B16", itemCode: "⑦", itemName: "측판(전산판)", spec: "", lotNo: "LOT-M-2024-016", requiredQty: 0, qty: 2 },
],
},
{
processName: "4. 연기단자",
sizeSpec: "",
items: [
{ id: "B17", itemCode: "-", itemName: "레일홀 (W50)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-017", requiredQty: 0, qty: 4 },
{ id: "B18", itemCode: "-", itemName: "카이드홀 (W60)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-018", requiredQty: 0, qty: 4 },
],
},
];
export default function ProductionOrderDetailPage() {
const router = useRouter();
const params = useParams();
const productionOrderId = params.id as string;
const orderId = params.id as string;
const [productionOrder, setProductionOrder] = useState<ProductionOrderDetail | null>(null);
const [detail, setDetail] = useState<ProductionOrderDetail | null>(null);
const [loading, setLoading] = useState(true);
const [isCreateWorkOrderDialogOpen, setIsCreateWorkOrderDialogOpen] = useState(false);
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
const [createdWorkOrders, setCreatedWorkOrders] = useState<string[]>([]);
const [isCreating, setIsCreating] = useState(false);
const [bomOpen, setBomOpen] = useState(false);
// 데이터 로드
const loadDetail = async () => {
setLoading(true);
const result = await getProductionOrderDetail(orderId);
if (result.success && result.data) {
setDetail(result.data);
} else {
setDetail(null);
}
setLoading(false);
};
useEffect(() => {
setTimeout(() => {
const found = SAMPLE_PRODUCTION_ORDER_DETAILS[productionOrderId];
setProductionOrder(found || null);
setLoading(false);
}, 300);
}, [productionOrderId]);
loadDetail();
}, [orderId]);
const handleBack = () => {
router.push("/sales/order-management-sales/production-orders");
@@ -423,19 +230,13 @@ export default function ProductionOrderDetailPage() {
const handleConfirmCreateWorkOrder = async () => {
setIsCreating(true);
try {
// API 호출 시뮬레이션
await new Promise((resolve) => setTimeout(resolve, 500));
// 생성된 작업지시서 목록 (실제로는 API 응답에서 받음)
const workOrderCount = productionOrder?.pendingWorkOrderCount || 0;
const created = Array.from({ length: workOrderCount }, (_, i) =>
`KD-WO-251223-${String(i + 1).padStart(2, "0")}`
);
setCreatedWorkOrders(created);
// 확인 팝업 닫고 성공 팝업 열기
setIsCreateWorkOrderDialogOpen(false);
setIsSuccessDialogOpen(true);
const result = await createProductionOrder(orderId);
if (result.success) {
setIsCreateWorkOrderDialogOpen(false);
setIsSuccessDialogOpen(true);
} else {
toast.error(result.error || "작업지시 생성에 실패했습니다.");
}
} finally {
setIsCreating(false);
}
@@ -457,7 +258,7 @@ export default function ProductionOrderDetailPage() {
);
}
if (!productionOrder) {
if (!detail) {
return (
<ServerErrorPage
title="생산지시 정보를 불러올 수 없습니다"
@@ -468,6 +269,9 @@ export default function ProductionOrderDetailPage() {
);
}
const hasWorkOrders = detail.workOrders.length > 0;
const canCreateWorkOrders = detail.productionStatus === "waiting" && !hasWorkOrders;
return (
<PageLayout>
{/* 헤더 */}
@@ -476,9 +280,9 @@ export default function ProductionOrderDetailPage() {
<div className="flex items-center gap-3">
<span> </span>
<code className="text-sm font-mono bg-blue-50 text-blue-700 px-2 py-1 rounded">
{productionOrder.productionOrderNumber}
{detail.orderNumber}
</code>
{getStatusBadge(productionOrder.status)}
{getStatusBadge(detail.productionStatus)}
</div>
}
icon={Factory}
@@ -488,10 +292,7 @@ export default function ProductionOrderDetailPage() {
<ArrowLeft className="h-4 w-4 mr-2" />
</Button>
{/* 작업지시 생성 버튼 - 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
{productionOrder.status !== "completed" &&
productionOrder.workOrders.length === 0 &&
productionOrder.pendingWorkOrderCount > 0 && (
{canCreateWorkOrders && (
<Button onClick={handleCreateWorkOrder}>
<ClipboardList className="h-4 w-4 mr-2" />
@@ -503,7 +304,7 @@ export default function ProductionOrderDetailPage() {
<div className="space-y-6">
{/* 공정 진행 현황 */}
<ProcessProgress workOrders={productionOrder.workOrders} />
<ProcessProgress workOrders={detail.workOrders} />
{/* 기본 정보 & 거래처/현장 정보 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
@@ -514,11 +315,10 @@ export default function ProductionOrderDetailPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="생산지시번호" value={productionOrder.productionOrderNumber} />
<InfoItem label="수주번호" value={productionOrder.orderNumber} />
<InfoItem label="생산지시일" value={productionOrder.productionOrderDate} />
<InfoItem label="납기일" value={productionOrder.dueDate} />
<InfoItem label="수량" value={`${productionOrder.quantity}`} />
<InfoItem label="수주번호" value={detail.orderNumber} />
<InfoItem label="생산지시일" value={detail.productionOrderedAt} />
<InfoItem label="납기일" value={detail.deliveryDate} />
<InfoItem label="개소" value={`${formatNumber(detail.nodeCount)}개소`} />
</div>
</CardContent>
</Card>
@@ -530,112 +330,108 @@ export default function ProductionOrderDetailPage() {
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<InfoItem label="거래처" value={productionOrder.client} />
<InfoItem label="현장명" value={productionOrder.siteName} />
<InfoItem label="제품유형" value={productionOrder.productType} />
<InfoItem label="거래처" value={detail.clientName} />
<InfoItem label="현장명" value={detail.siteName} />
</div>
</CardContent>
</Card>
</div>
{/* BOM 품목별 공정 분류 */}
<Card>
<CardHeader>
<CardTitle className="text-base">BOM </CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 절곡 부품 전개도 정보 헤더 */}
<p className="text-sm font-medium text-muted-foreground border-b pb-2">
</p>
{/* 공정별 테이블 */}
{SAMPLE_BOM_PROCESS_GROUPS.map((group) => (
<div key={group.processName} className="space-y-2">
{/* 공정명 헤더 */}
<h4 className="text-sm font-semibold">
{group.processName}
{group.sizeSpec && (
<span className="ml-2 text-muted-foreground font-normal">
{group.sizeSpec}
</span>
)}
</h4>
{/* BOM 품목 테이블 */}
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="w-[60px] text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>LOT NO</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-center w-[60px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-center font-medium">
{item.itemCode}
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">
{item.spec || "-"}
</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.lotNo}
</code>
</TableCell>
<TableCell className="text-right">
{item.requiredQty > 0 ? formatNumber(item.requiredQty) : "-"}
</TableCell>
<TableCell className="text-center">{item.qty}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* BOM 품목별 공정 분류 (접이식) */}
{detail.bomProcessGroups.length > 0 && (
<Card>
<CardHeader
className="cursor-pointer select-none"
onClick={() => setBomOpen((prev) => !prev)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-base">
BOM
<span className="ml-2 text-sm font-normal text-muted-foreground">
({detail.bomProcessGroups.length} )
</span>
</CardTitle>
<ChevronDown
className={`h-5 w-5 text-muted-foreground transition-transform ${
bomOpen ? "rotate-180" : ""
}`}
/>
</div>
))}
</CardHeader>
{bomOpen && (
<CardContent className="space-y-6 pt-0">
{detail.bomProcessGroups.map((group) => (
<div key={group.processName} className="space-y-2">
<h4 className="text-sm font-semibold flex items-center gap-2">
<Badge variant="outline">{group.processName}</Badge>
<span className="text-muted-foreground font-normal text-xs">
{group.items.length}
</span>
</h4>
{/* 합계 정보 */}
<div className="flex justify-between items-center pt-4 border-t text-sm">
<span className="text-muted-foreground"> 종류: 18개</span>
<span className="text-muted-foreground"> 중량: 25.8 kg</span>
<span className="text-muted-foreground">비고: VT칼 </span>
</div>
</CardContent>
</Card>
<div className="border rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{group.items.map((item, idx) => (
<TableRow key={`${item.id}-${idx}`}>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.itemCode}
</code>
</TableCell>
<TableCell>{item.itemName}</TableCell>
<TableCell className="text-muted-foreground">
{item.spec || "-"}
</TableCell>
<TableCell className="text-center">{item.unit || "-"}</TableCell>
<TableCell className="text-right">{formatNumber(item.quantity)}</TableCell>
<TableCell className="text-right">{formatNumber(item.unitPrice)}</TableCell>
<TableCell className="text-right">{formatNumber(item.totalPrice)}</TableCell>
<TableCell className="text-muted-foreground text-xs">{item.nodeName || "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
))}
</CardContent>
)}
</Card>
)}
{/* 작업지시서 목록 */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base"> </CardTitle>
{/* 버튼 조건: 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
{productionOrder.status !== "completed" &&
productionOrder.workOrders.length === 0 &&
productionOrder.pendingWorkOrderCount > 0 && (
{canCreateWorkOrders && (
<Button onClick={handleCreateWorkOrder}>
<Play className="h-4 w-4 mr-2" />
{productionOrder.pendingWorkOrderCount > 1
? "작업지시 일괄생성"
: "작업지시 생성"}
</Button>
)}
</CardHeader>
<CardContent>
{productionOrder.workOrders.length === 0 ? (
{!hasWorkOrders ? (
<div className="text-center py-8">
<div className="flex flex-col items-center gap-2">
<ClipboardList className="h-12 w-12 text-gray-300" />
<p className="text-muted-foreground text-sm">
.
</p>
{productionOrder.pendingWorkOrderCount > 0 && (
{canCreateWorkOrders && (
<p className="text-sm text-muted-foreground">
BOM .
</p>
@@ -649,23 +445,23 @@ export default function ProductionOrderDetailPage() {
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead className="text-center"></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{productionOrder.workOrders.map((wo) => (
{detail.workOrders.map((wo) => (
<TableRow key={wo.id}>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{wo.workOrderNumber}
{wo.workOrderNo}
</code>
</TableCell>
<TableCell>{wo.process}</TableCell>
<TableCell className="text-center">{wo.quantity}</TableCell>
<TableCell>{wo.processName}</TableCell>
<TableCell className="text-center">{wo.quantity}</TableCell>
<TableCell>{getWorkOrderStatusBadge(wo.status)}</TableCell>
<TableCell>{wo.assignee}</TableCell>
<TableCell>{wo.assignees.length > 0 ? wo.assignees.join(", ") : "-"}</TableCell>
</TableRow>
))}
</TableBody>
@@ -676,7 +472,7 @@ export default function ProductionOrderDetailPage() {
</Card>
</div>
{/* 팝업1: 작업지시 생성 확인 다이얼로그 */}
{/* 작업지시 생성 확인 다이얼로그 */}
<ConfirmDialog
open={isCreateWorkOrderDialogOpen}
onOpenChange={setIsCreateWorkOrderDialogOpen}
@@ -685,19 +481,10 @@ export default function ProductionOrderDetailPage() {
description={
<div className="space-y-4 pt-2">
<p className="font-medium text-foreground">
:
.
</p>
{productionOrder?.pendingWorkOrderCount && productionOrder.pendingWorkOrderCount > 0 && (
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
{SAMPLE_PROCESSES.slice(0, productionOrder.pendingWorkOrderCount).map((process) => (
<li key={process.id} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
{process.name} ({process.quantity})
</li>
))}
</ul>
)}
<p className="text-muted-foreground">
BOM .
.
</p>
</div>
@@ -706,7 +493,7 @@ export default function ProductionOrderDetailPage() {
loading={isCreating}
/>
{/* 팝업2: 작업지시 생성 성공 다이얼로그 */}
{/* 작업지시 생성 성공 다이얼로그 */}
<AlertDialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
<AlertDialogContent className="max-w-md">
<AlertDialogHeader>
@@ -716,24 +503,9 @@ export default function ProductionOrderDetailPage() {
<div className="flex items-center gap-2">
<CheckCircle2 className="h-5 w-5 text-green-600" />
<span className="font-medium text-foreground">
{createdWorkOrders.length} .
.
</span>
</div>
<div>
<p className="text-sm font-medium text-foreground mb-2"> :</p>
{createdWorkOrders.length > 0 ? (
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
{createdWorkOrders.map((wo, idx) => (
<li key={wo} className="flex items-center gap-2">
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
{wo}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">-</p>
)}
</div>
<p className="text-muted-foreground">
.
</p>
@@ -749,4 +521,4 @@ export default function ProductionOrderDetailPage() {
</AlertDialog>
</PageLayout>
);
}
}

View File

@@ -4,24 +4,20 @@
* 생산지시 목록 페이지
*
* - 수주관리 > 생산지시 보기에서 접근
* - 진행 단계 바
* - 진행 단계 바 (Order 상태 기반 동적)
* - 필터 탭: 전체, 생산대기, 생산중, 생산완료 (TabChip 사용)
* - IntegratedListTemplateV2 템플릿 적용
* - 서버사이드 페이지네이션
*/
import { useState } from "react";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
TableCell,
} from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import {
@@ -29,7 +25,6 @@ import {
ArrowLeft,
CheckCircle2,
Eye,
Trash2,
} from "lucide-react";
import {
UniversalListPage,
@@ -39,136 +34,63 @@ import {
} from "@/components/templates/UniversalListPage";
import { BadgeSm } from "@/components/atoms/BadgeSm";
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
// 생산지시 상태 타입
type ProductionOrderStatus =
| "waiting" // 생산대기
| "in_progress" // 생산중
| "completed"; // 생산완료
// 생산지시 데이터 타입
interface ProductionOrder {
id: string;
productionOrderNumber: string; // PO-KD-TS-XXXXXX-XX
orderNumber: string; // KD-TS-XXXXXX-XX
siteName: string;
client: string;
quantity: number;
dueDate: string;
productionOrderDate: string;
status: ProductionOrderStatus;
workOrderCount: number;
}
// 샘플 생산지시 데이터
const SAMPLE_PRODUCTION_ORDERS: ProductionOrder[] = [
{
id: "PO-001",
productionOrderNumber: "PO-KD-TS-251217-07",
orderNumber: "KD-TS-251217-07",
siteName: "씨밋 광교 센트럴시티",
client: "호반건설(주)",
quantity: 2,
dueDate: "2026-02-15",
productionOrderDate: "2025-12-22",
status: "waiting",
workOrderCount: 3,
},
{
id: "PO-002",
productionOrderNumber: "PO-KD-TS-251217-09",
orderNumber: "KD-TS-251217-09",
siteName: "데시앙 동탄 파크뷰",
client: "태영건설(주)",
quantity: 10,
dueDate: "2026-02-10",
productionOrderDate: "2025-12-22",
status: "waiting",
workOrderCount: 0,
},
{
id: "PO-003",
productionOrderNumber: "PO-KD-TS-251217-06",
orderNumber: "KD-TS-251217-06",
siteName: "예술 검실 푸르지오",
client: "롯데건설(주)",
quantity: 1,
dueDate: "2026-02-10",
productionOrderDate: "2025-12-22",
status: "waiting",
workOrderCount: 0,
},
{
id: "PO-004",
productionOrderNumber: "PO-KD-BD-251220-35",
orderNumber: "KD-BD-251220-35",
siteName: "[코레타스프] 판교 물류센터 철거현장",
client: "현대건설(주)",
quantity: 3,
dueDate: "2026-02-03",
productionOrderDate: "2025-12-20",
status: "in_progress",
workOrderCount: 2,
},
{
id: "PO-005",
productionOrderNumber: "PO-KD-BD-251219-34",
orderNumber: "KD-BD-251219-34",
siteName: "[코레타스프1] 김포 6차 필라테스장",
client: "신성플랜(주)",
quantity: 2,
dueDate: "2026-01-15",
productionOrderDate: "2025-12-19",
status: "in_progress",
workOrderCount: 3,
},
{
id: "PO-006",
productionOrderNumber: "PO-KD-TS-250401-29",
orderNumber: "KD-TS-250401-29",
siteName: "포레나 전주",
client: "한화건설(주)",
quantity: 2,
dueDate: "2025-05-16",
productionOrderDate: "2025-04-01",
status: "completed",
workOrderCount: 3,
},
{
id: "PO-007",
productionOrderNumber: "PO-KD-BD-250331-28",
orderNumber: "KD-BD-250331-28",
siteName: "포레나 수원",
client: "포레나건설(주)",
quantity: 4,
dueDate: "2025-05-15",
productionOrderDate: "2025-03-31",
status: "completed",
workOrderCount: 3,
},
{
id: "PO-008",
productionOrderNumber: "PO-KD-TS-250314-23",
orderNumber: "KD-TS-250314-23",
siteName: "자이 흑산파크",
client: "GS건설(주)",
quantity: 3,
dueDate: "2025-04-28",
productionOrderDate: "2025-03-14",
status: "completed",
workOrderCount: 3,
},
];
import {
getProductionOrders,
getProductionOrderStats,
} from "@/components/production/ProductionOrders/actions";
import type {
ProductionOrder,
ProductionStatus,
ProductionOrderStats,
} from "@/components/production/ProductionOrders/types";
import { formatNumber } from '@/lib/utils/amount';
// 진행 단계 컴포넌트
function ProgressSteps() {
const steps = [
{ label: "수주확정", active: true, completed: true },
{ label: "생산지시", active: true, completed: false },
{ label: "작업지시", active: false, completed: false },
{ label: "생산", active: false, completed: false },
{ label: "검사출하", active: false, completed: false },
];
function ProgressSteps({ statusCode }: { statusCode?: string }) {
const getSteps = () => {
// 기본: 생산지시 목록에 있으면 수주확정, 생산지시는 이미 완료
const steps = [
{ label: "수주확정", completed: true, active: false },
{ label: "생산지시", completed: true, active: false },
{ label: "작업지시", completed: false, active: false },
{ label: "생산", completed: false, active: false },
{ label: "검사출하", completed: false, active: false },
];
if (!statusCode) return steps;
// IN_PROGRESS = 생산대기 (작업지시 배정 진행 중)
if (statusCode === "IN_PROGRESS") {
steps[2].active = true;
}
// IN_PRODUCTION = 생산중
if (statusCode === "IN_PRODUCTION") {
steps[2].completed = true;
steps[3].active = true;
}
// PRODUCED = 생산완료
if (statusCode === "PRODUCED") {
steps[2].completed = true;
steps[3].completed = true;
steps[4].active = true;
}
// SHIPPING = 출하중
if (statusCode === "SHIPPING") {
steps[2].completed = true;
steps[3].completed = true;
steps[4].active = true;
}
// SHIPPED = 출하완료
if (statusCode === "SHIPPED") {
steps[2].completed = true;
steps[3].completed = true;
steps[4].completed = true;
}
return steps;
};
const steps = getSteps();
return (
<div className="flex items-center justify-center gap-2 py-4">
@@ -214,16 +136,16 @@ function ProgressSteps() {
}
// 상태 배지 헬퍼
function getStatusBadge(status: ProductionOrderStatus) {
function getStatusBadge(status: ProductionStatus) {
const config: Record<
ProductionOrderStatus,
ProductionStatus,
{ label: string; className: string }
> = {
waiting: {
label: "생산대기",
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
},
in_progress: {
in_production: {
label: "생산중",
className: "bg-green-100 text-green-700 border-green-200",
},
@@ -239,13 +161,12 @@ function getStatusBadge(status: ProductionOrderStatus) {
// 테이블 컬럼 정의
const TABLE_COLUMNS: TableColumn[] = [
{ key: "no", label: "번호", className: "w-[60px] text-center" },
{ key: "productionOrderNumber", label: "생산지시번호", className: "min-w-[150px]" },
{ key: "orderNumber", label: "수주번호", className: "min-w-[140px]" },
{ key: "orderNumber", label: "수주번호", className: "min-w-[150px]" },
{ key: "siteName", label: "현장명", className: "min-w-[180px]" },
{ key: "client", label: "거래처", className: "min-w-[120px]" },
{ key: "quantity", label: "수량", className: "w-[80px] text-center" },
{ key: "dueDate", label: "납기", className: "w-[110px]" },
{ key: "productionOrderDate", label: "생산지시일", className: "w-[110px]" },
{ key: "clientName", label: "거래처", className: "min-w-[120px]" },
{ key: "nodeCount", label: "개소", className: "w-[80px] text-center" },
{ key: "deliveryDate", label: "납기", className: "w-[110px]" },
{ key: "productionOrderedAt", label: "생산지시일", className: "w-[110px]" },
{ key: "status", label: "상태", className: "w-[100px]" },
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center" },
{ key: "actions", label: "작업", className: "w-[100px] text-center" },
@@ -253,65 +174,21 @@ const TABLE_COLUMNS: TableColumn[] = [
export default function ProductionOrdersListPage() {
const router = useRouter();
const [orders, setOrders] = useState<ProductionOrder[]>(SAMPLE_PRODUCTION_ORDERS);
const [searchTerm, setSearchTerm] = useState("");
const [activeTab, setActiveTab] = useState("all");
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 삭제 확인 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); // 개별 삭제 시 사용
// 필터링된 데이터
const filteredData = orders.filter((item) => {
// 탭 필터
if (activeTab !== "all") {
const statusMap: Record<string, ProductionOrderStatus> = {
waiting: "waiting",
in_progress: "in_progress",
completed: "completed",
};
if (item.status !== statusMap[activeTab]) return false;
}
// 검색 필터
if (searchTerm) {
const term = searchTerm.toLowerCase();
return (
item.productionOrderNumber.toLowerCase().includes(term) ||
item.orderNumber.toLowerCase().includes(term) ||
item.siteName.toLowerCase().includes(term) ||
item.client.toLowerCase().includes(term)
);
}
return true;
const [stats, setStats] = useState<ProductionOrderStats>({
total: 0,
waiting: 0,
in_production: 0,
completed: 0,
});
// 페이지네이션
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
const paginatedData = filteredData.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
// 탭별 건수
const tabCounts = {
all: orders.length,
waiting: orders.filter((i) => i.status === "waiting").length,
in_progress: orders.filter((i) => i.status === "in_progress").length,
completed: orders.filter((i) => i.status === "completed").length,
};
// 탭 옵션
const tabs: TabOption[] = [
{ value: "all", label: "전체", count: tabCounts.all },
{ value: "waiting", label: "생산대기", count: tabCounts.waiting, color: "yellow" },
{ value: "in_progress", label: "생산중", count: tabCounts.in_progress, color: "green" },
{ value: "completed", label: "생산완료", count: tabCounts.completed, color: "gray" },
];
// 통계 로드
useEffect(() => {
getProductionOrderStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}, []);
const handleBack = () => {
router.push("/sales/order-management-sales");
@@ -325,57 +202,13 @@ export default function ProductionOrdersListPage() {
router.push(`/sales/order-management-sales/production-orders/${item.id}?mode=view`);
};
// 개별 삭제 다이얼로그 열기
const handleDelete = (item: ProductionOrder) => {
setDeleteTargetId(item.id);
setShowDeleteDialog(true);
};
// 체크박스 선택
const toggleSelection = (id: string) => {
const newSelection = new Set(selectedItems);
if (newSelection.has(id)) {
newSelection.delete(id);
} else {
newSelection.add(id);
}
setSelectedItems(newSelection);
};
const toggleSelectAll = () => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
}
};
// 일괄 삭제 다이얼로그 열기
const handleBulkDelete = () => {
if (selectedItems.size > 0) {
setDeleteTargetId(null); // 일괄 삭제
setShowDeleteDialog(true);
}
};
// 삭제 개수 계산 (개별 삭제 시 1, 일괄 삭제 시 selectedItems.size)
const deleteCount = deleteTargetId ? 1 : selectedItems.size;
// 실제 삭제 실행
const handleConfirmDelete = () => {
if (deleteTargetId) {
// 개별 삭제
setOrders(orders.filter((o) => o.id !== deleteTargetId));
setSelectedItems(new Set([...selectedItems].filter(id => id !== deleteTargetId)));
} else {
// 일괄 삭제
const selectedIds = Array.from(selectedItems);
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
setSelectedItems(new Set());
}
setShowDeleteDialog(false);
setDeleteTargetId(null);
};
// 탭 옵션 (통계 기반 동적 카운트)
const tabs: TabOption[] = [
{ value: "all", label: "전체", count: stats.total },
{ value: "waiting", label: "생산대기", count: stats.waiting, color: "yellow" },
{ value: "in_production", label: "생산중", count: stats.in_production, color: "green" },
{ value: "completed", label: "생산완료", count: stats.completed, color: "gray" },
];
// 테이블 행 렌더링
const renderTableRow = (
@@ -402,22 +235,17 @@ export default function ProductionOrdersListPage() {
</TableCell>
<TableCell>
<code className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">
{item.productionOrderNumber}
</code>
</TableCell>
<TableCell>
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
{item.orderNumber}
</code>
</TableCell>
<TableCell className="max-w-[200px] truncate">
{item.siteName}
</TableCell>
<TableCell>{item.client}</TableCell>
<TableCell className="text-center">{item.quantity}</TableCell>
<TableCell>{item.dueDate}</TableCell>
<TableCell>{item.productionOrderDate}</TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell>
<TableCell>{item.clientName}</TableCell>
<TableCell className="text-center">{formatNumber(item.nodeCount)}</TableCell>
<TableCell>{item.deliveryDate}</TableCell>
<TableCell>{item.productionOrderedAt}</TableCell>
<TableCell>{getStatusBadge(item.productionStatus)}</TableCell>
<TableCell className="text-center">
{item.workOrderCount > 0 ? (
<Badge variant="outline">{item.workOrderCount}</Badge>
@@ -431,9 +259,6 @@ export default function ProductionOrdersListPage() {
<Button variant="ghost" size="sm" onClick={() => handleView(item)}>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(item)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
@@ -463,19 +288,19 @@ export default function ProductionOrdersListPage() {
variant="outline"
className="bg-blue-50 text-blue-700 font-mono text-xs"
>
{item.productionOrderNumber}
{item.orderNumber}
</Badge>
{getStatusBadge(item.status)}
{getStatusBadge(item.productionStatus)}
</>
}
infoGrid={
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
<InfoField label="수주번호" value={item.orderNumber} />
<InfoField label="현장명" value={item.siteName} />
<InfoField label="거래처" value={item.client} />
<InfoField label="수량" value={`${item.quantity}`} />
<InfoField label="납기" value={item.dueDate} />
<InfoField label="생산지시일" value={item.productionOrderDate} />
<InfoField label="거래처" value={item.clientName} />
<InfoField label="개소" value={`${formatNumber(item.nodeCount)}`} />
<InfoField label="납기" value={item.deliveryDate} />
<InfoField label="생산지시일" value={item.productionOrderedAt} />
<InfoField
label="작업지시"
value={item.workOrderCount > 0 ? `${item.workOrderCount}` : "-"}
@@ -497,18 +322,6 @@ export default function ProductionOrdersListPage() {
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button
variant="outline"
size="default"
className="flex-1 min-w-[100px] h-11"
onClick={(e) => {
e.stopPropagation();
handleDelete(item);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
</Button>
</div>
) : undefined
}
@@ -516,6 +329,43 @@ export default function ProductionOrdersListPage() {
);
};
// getList API 호출
const getList = useCallback(async (params?: { page?: number; pageSize?: number; search?: string; tab?: string }) => {
const productionStatus = params?.tab && params.tab !== "all"
? (params.tab as ProductionStatus)
: undefined;
const result = await getProductionOrders({
search: params?.search,
productionStatus,
page: params?.page,
perPage: params?.pageSize,
});
if (result.success) {
// 통계 새로고침
getProductionOrderStats().then((statsResult) => {
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
});
return {
success: true,
data: result.data,
totalCount: result.pagination?.total || 0,
totalPages: result.pagination?.lastPage || 1,
};
}
return {
success: false,
data: [],
totalCount: 0,
error: result.error,
};
}, []);
// ===== UniversalListPage 설정 =====
const productionOrderConfig: UniversalListConfig<ProductionOrder> = {
title: "생산지시 목록",
@@ -525,43 +375,19 @@ export default function ProductionOrdersListPage() {
idField: "id",
actions: {
getList: async () => ({
success: true,
data: orders,
totalCount: orders.length,
}),
getList,
},
columns: TABLE_COLUMNS,
tabs: tabs,
defaultTab: activeTab,
defaultTab: "all",
searchPlaceholder: "생산지시번호, 수주번호, 현장명 검색...",
searchPlaceholder: "수주번호, 현장명, 거래처 검색...",
itemsPerPage,
itemsPerPage: 20,
clientSideFiltering: true,
searchFilter: (item, searchValue) => {
const term = searchValue.toLowerCase();
return (
item.productionOrderNumber.toLowerCase().includes(term) ||
item.orderNumber.toLowerCase().includes(term) ||
item.siteName.toLowerCase().includes(term) ||
item.client.toLowerCase().includes(term)
);
},
tabFilter: (item, tabValue) => {
if (tabValue === "all") return true;
const statusMap: Record<string, ProductionOrderStatus> = {
waiting: "waiting",
in_progress: "in_progress",
completed: "completed",
};
return item.status === statusMap[tabValue];
},
clientSideFiltering: false,
headerActions: () => (
<Button variant="outline" onClick={handleBack}>
@@ -580,50 +406,11 @@ export default function ProductionOrdersListPage() {
renderTableRow,
renderMobileCard,
renderDialogs: () => (
<DeleteConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleConfirmDelete}
title="삭제 확인"
description={
<>
<strong>{deleteCount}</strong> ?
<br />
<span className="text-muted-foreground text-sm">
. .
</span>
</>
}
/>
),
};
return (
<UniversalListPage<ProductionOrder>
config={productionOrderConfig}
initialData={orders}
initialTotalCount={orders.length}
externalSelection={{
selectedItems,
onToggleSelection: toggleSelection,
onToggleSelectAll: toggleSelectAll,
setSelectedItems,
getItemId: (item: ProductionOrder) => item.id,
}}
onTabChange={(value: string) => {
setActiveTab(value);
setCurrentPage(1);
}}
onSearchChange={setSearchTerm}
externalPagination={{
currentPage,
totalPages,
totalItems: filteredData.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
);
}
}

View File

@@ -114,9 +114,21 @@ export async function POST(request: NextRequest) {
deviceScaleFactor: 2,
});
// HTML 설정
// 외부 리소스 요청 차단 (이미지는 이미 base64 인라인)
await page.setRequestInterception(true);
page.on('request', (req) => {
const resourceType = req.resourceType();
// 이미지/폰트/스타일시트 등 외부 리소스 차단 → 타임아웃 방지
if (['image', 'font', 'stylesheet', 'media'].includes(resourceType)) {
req.abort();
} else {
req.continue();
}
});
// HTML 설정 (domcontentloaded: 외부 리소스 대기 안 함)
await page.setContent(fullHtml, {
waitUntil: 'networkidle0',
waitUntil: 'domcontentloaded',
});
// 헤더 템플릿 (문서번호, 생성일)

View File

@@ -39,8 +39,10 @@ import type {
} from './types';
import {
STATUS_SELECT_OPTIONS,
COLLECTION_END_REASON_OPTIONS,
VENDOR_TYPE_LABELS,
} from './types';
import type { CollectionEndReason } from './types';
import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -87,6 +89,7 @@ const getEmptyRecord = (): Omit<BadDebtRecord, 'id' | 'createdAt' | 'updatedAt'>
assignedManagerId: null,
assignedManager: null,
settingToggle: true,
collectionEndReason: undefined,
badDebtCount: 0,
badDebts: [],
files: [],
@@ -778,22 +781,47 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
{/* 상태 */}
<div className="space-y-2">
<Label className="text-sm font-medium text-gray-700"></Label>
<Select
value={formData.status}
onValueChange={(val) => handleChange('status', val as CollectionStatus)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{STATUS_SELECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex items-center gap-2">
<Select
value={formData.status}
onValueChange={(val) => {
handleChange('status', val as CollectionStatus);
if (val !== 'collectionEnd') {
handleChange('collectionEndReason', null);
}
}}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{STATUS_SELECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{formData.status === 'collectionEnd' && (
<Select
value={formData.collectionEndReason || ''}
onValueChange={(val) => handleChange('collectionEndReason', val as CollectionEndReason)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectValue placeholder="종료사유 선택" />
</SelectTrigger>
<SelectContent>
{COLLECTION_END_REASON_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
</div>
{/* 연체일수 */}
<div className="space-y-2">

View File

@@ -72,8 +72,9 @@ function mapApiStatusToFrontend(apiStatus: string): CollectionStatus {
switch (apiStatus) {
case 'collecting': return 'collecting';
case 'legal_action': return 'legalAction';
case 'recovered': return 'recovered';
case 'bad_debt': return 'badDebt';
case 'recovered':
case 'bad_debt':
case 'collection_end': return 'collectionEnd';
default: return 'collecting';
}
}
@@ -82,8 +83,7 @@ function mapFrontendStatusToApi(status: CollectionStatus): string {
switch (status) {
case 'collecting': return 'collecting';
case 'legalAction': return 'legal_action';
case 'recovered': return 'recovered';
case 'badDebt': return 'bad_debt';
case 'collectionEnd': return 'collection_end';
default: return 'collecting';
}
}

View File

@@ -15,7 +15,8 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
import { useState, useMemo, useCallback, useTransition } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle } from 'lucide-react';
import { AlertTriangle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
@@ -56,6 +57,7 @@ const tableColumns = [
{ key: 'managerName', label: '담당자', className: 'w-[100px]', sortable: true },
{ key: 'status', label: '상태', className: 'text-center w-[100px]', sortable: true },
{ key: 'setting', label: '설정', className: 'text-center w-[80px]' },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];
// ===== Props 타입 정의 =====
@@ -65,8 +67,7 @@ interface BadDebtCollectionProps {
total_amount: number;
collecting_amount: number;
legal_action_amount: number;
recovered_amount: number;
bad_debt_amount: number;
collection_end_amount: number;
} | null;
}
@@ -132,7 +133,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
totalAmount: initialSummary.total_amount,
collectingAmount: initialSummary.collecting_amount,
legalActionAmount: initialSummary.legal_action_amount,
recoveredAmount: initialSummary.recovered_amount,
collectionEndAmount: initialSummary.collection_end_amount,
};
}
@@ -144,11 +145,11 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
const legalActionAmount = data
.filter((d) => d.status === 'legalAction')
.reduce((sum, d) => sum + d.debtAmount, 0);
const recoveredAmount = data
.filter((d) => d.status === 'recovered')
const collectionEndAmount = data
.filter((d) => d.status === 'collectionEnd')
.reduce((sum, d) => sum + d.debtAmount, 0);
return { totalAmount, collectingAmount, legalActionAmount, recoveredAmount };
return { totalAmount, collectingAmount, legalActionAmount, collectionEndAmount };
}, [data, initialSummary]);
// ===== UniversalListPage Config =====
@@ -335,7 +336,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
},
{
label: '회수완료',
value: `${formatNumber(statsData.recoveredAmount)}`,
value: `${formatNumber(statsData.collectionEndAmount)}`,
icon: AlertTriangle,
iconColor: 'text-green-500',
},
@@ -390,6 +391,27 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
disabled={isPending}
/>
</TableCell>
{/* 작업 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=edit`)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-500 hover:text-red-700"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
),

View File

@@ -1,7 +1,15 @@
// ===== 악성채권 추심관리 타입 정의 =====
// 추심 상태
export type CollectionStatus = 'collecting' | 'legalAction' | 'recovered' | 'badDebt';
export type CollectionStatus = 'collecting' | 'legalAction' | 'collectionEnd';
// 추심종료 사유
export type CollectionEndReason = 'recovered' | 'badDebt';
export const COLLECTION_END_REASON_OPTIONS: { value: CollectionEndReason; label: string }[] = [
{ value: 'recovered', label: '회수완료' },
{ value: 'badDebt', label: '대손처리' },
];
// 정렬 옵션
export type SortOption = 'latest' | 'oldest';
@@ -70,6 +78,7 @@ export interface BadDebtRecord {
debtAmount: number; // 총 미수금액
badDebtCount: number; // 악성채권 건수
status: CollectionStatus; // 대표 상태 (가장 최근)
collectionEndReason?: CollectionEndReason; // 추심종료 사유 (status === 'collectionEnd'일 때)
overdueDays: number; // 최대 연체일수
overdueToggle: boolean;
occurrenceDate: string;

View File

@@ -51,12 +51,27 @@ import {
getBankAccountOptions,
getFinancialInstitutions,
batchSaveTransactions,
exportBankTransactionsExcel,
type BankTransactionSummaryData,
} from './actions';
import { TransactionFormModal } from './TransactionFormModal';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<BankTransaction & Record<string, unknown>>[] = [
{ header: '거래일시', key: 'transactionDate', width: 12 },
{ header: '구분', key: 'type', width: 8,
transform: (v) => v === 'deposit' ? '입금' : '출금' },
{ header: '은행명', key: 'bankName', width: 12 },
{ header: '계좌명', key: 'accountName', width: 15 },
{ header: '적요/내용', key: 'note', width: 20 },
{ header: '입금', key: 'depositAmount', width: 14 },
{ header: '출금', key: 'withdrawalAmount', width: 14 },
{ header: '잔액', key: 'balance', width: 14 },
{ header: '취급점', key: 'branch', width: 12 },
{ header: '상대계좌예금주명', key: 'depositorName', width: 18 },
];
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
const tableColumns = [
@@ -226,22 +241,45 @@ export function BankTransactionInquiry() {
}
}, [localChanges, loadData]);
// 엑셀 다운로드
// 엑셀 다운로드 (프론트 xlsx 생성)
const handleExcelDownload = useCallback(async () => {
try {
const result = await exportBankTransactionsExcel({
startDate,
endDate,
accountCategory: accountCategoryFilter,
financialInstitution: financialInstitutionFilter,
});
if (result.success && result.data) {
window.open(result.data.downloadUrl, '_blank');
toast.info('엑셀 파일 생성 중...');
const allData: BankTransaction[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getBankTransactionList({
startDate,
endDate,
accountCategory: accountCategoryFilter,
financialInstitution: financialInstitutionFilter,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({
data: allData as (BankTransaction & Record<string, unknown>)[],
columns: excelColumns,
filename: '계좌입출금내역',
sheetName: '입출금내역',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);

View File

@@ -1,99 +1,64 @@
'use client';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { billConfig } from './billConfig';
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
import { apiDataToFormData, transformFormDataToApi } from './types';
import type { BillApiData } from './types';
import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions';
import { useBillForm } from './hooks/useBillForm';
import { useBillConditions } from './hooks/useBillConditions';
import {
BILL_TYPE_OPTIONS,
getBillStatusOptions,
} from './types';
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
// ===== 새 훅 import =====
BasicInfoSection,
ElectronicBillSection,
ExchangeBillSection,
DiscountInfoSection,
EndorsementSection,
CollectionSection,
HistorySection,
RenewalSection,
RecourseSection,
BuybackSection,
DishonoredSection,
} from './sections';
import { useDetailData } from '@/hooks';
// ===== Props =====
interface BillDetailProps {
billId: string;
mode: 'view' | 'edit' | 'new';
}
// ===== 거래처 타입 =====
interface ClientOption {
id: string;
name: string;
}
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
interface BillFormData {
billNumber: string;
billType: BillType;
vendorId: string;
amount: number;
issueDate: string;
maturityDate: string;
status: BillStatus;
note: string;
installments: InstallmentRecord[];
}
const INITIAL_FORM_DATA: BillFormData = {
billNumber: '',
billType: 'received',
vendorId: '',
amount: 0,
issueDate: '',
maturityDate: '',
status: 'stored',
note: '',
installments: [],
};
export function BillDetail({ billId, mode }: BillDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// ===== 거래처 목록 =====
// 거래처 목록
const [clients, setClients] = useState<ClientOption[]>([]);
// ===== 폼 상태 (통합된 단일 state) =====
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
// V8 폼 훅
const {
formData,
updateField,
handleInstrumentTypeChange,
handleDirectionChange,
addInstallment,
removeInstallment,
updateInstallment,
setFormDataFull,
} = useBillForm();
// ===== 폼 필드 업데이트 헬퍼 =====
const updateField = useCallback(<K extends keyof BillFormData>(
field: K,
value: BillFormData[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 조건부 표시 플래그
const conditions = useBillConditions(formData);
// ===== 거래처 목록 로드 =====
// 거래처 목록 로드
useEffect(() => {
async function loadClients() {
const result = await getClients();
@@ -104,41 +69,30 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
loadClients();
}, []);
// ===== 새 훅: useDetailData로 데이터 로딩 =====
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
// API 데이터 로딩 (BillApiData 그대로)
const fetchBillWrapper = useCallback(
(id: string | number) => getBill(String(id)),
(id: string | number) => getBillRaw(String(id)),
[]
);
const {
data: billData,
data: billApiData,
isLoading,
error: loadError,
} = useDetailData<BillRecord>(
} = useDetailData<BillApiData>(
billId !== 'new' ? billId : null,
fetchBillWrapper,
{ skip: isNewMode }
);
// ===== 데이터 로드 시 폼에 반영 =====
// API 데이터 → V8 폼 데이터로 변환
useEffect(() => {
if (billData) {
setFormData({
billNumber: billData.billNumber,
billType: billData.billType,
vendorId: billData.vendorId,
amount: billData.amount,
issueDate: billData.issueDate,
maturityDate: billData.maturityDate,
status: billData.status,
note: billData.note,
installments: billData.installments,
});
if (billApiData) {
setFormDataFull(apiDataToFormData(billApiData));
}
}, [billData]);
}, [billApiData, setFormDataFull]);
// ===== 로드 에러 처리 =====
// 로드 에러
useEffect(() => {
if (loadError) {
toast.error(loadError);
@@ -146,43 +100,21 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
}
}, [loadError, router]);
// ===== 유효성 검사 함수 =====
// 유효성 검사
const validateForm = useCallback((): { valid: boolean; error?: string } => {
if (!formData.billNumber.trim()) {
return { valid: false, error: '어음번호를 입력해주세요.' };
}
if (!formData.vendorId) {
return { valid: false, error: '거래처를 선택해주세요.' };
}
if (formData.amount <= 0) {
return { valid: false, error: '금액을 입력해주세요.' };
}
if (!formData.issueDate) {
return { valid: false, error: '발행일을 입력해주세요.' };
}
if (!formData.maturityDate) {
return { valid: false, error: '만기일을 입력해주세요.' };
}
// 차수 유효성 검사
for (let i = 0; i < formData.installments.length; i++) {
const inst = formData.installments[i];
if (!inst.date) {
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
}
if (inst.amount <= 0) {
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
}
}
if (!formData.billNumber.trim()) return { valid: false, error: '어음번호를 입력해주세요.' };
const vendorId = conditions.isReceived ? formData.vendor : formData.payee;
if (!vendorId) return { valid: false, error: '거래처를 선택해주세요.' };
if (formData.amount <= 0) return { valid: false, error: '금액을 입력해주세요.' };
if (!formData.issueDate) return { valid: false, error: '발행일을 입력해주세요.' };
if (conditions.isBill && !formData.maturityDate) return { valid: false, error: '만기일을 입력해주세요.' };
return { valid: true };
}, [formData]);
}, [formData, conditions.isReceived, conditions.isBill]);
// ===== 제출 상태 =====
// 제출
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// ===== 저장 핸들러 =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
const validation = validateForm();
if (!validation.valid) {
@@ -192,28 +124,26 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
setIsSubmitting(true);
try {
const billData: Partial<BillRecord> = {
...formData,
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
};
const vendorName = clients.find(c => c.id === (conditions.isReceived ? formData.vendor : formData.payee))?.name || '';
const apiPayload = transformFormDataToApi(formData, vendorName);
if (isNewMode) {
const result = await createBill(billData);
const result = await createBillRaw(apiPayload);
if (result.success) {
toast.success('등록되었습니다.');
router.push('/ko/accounting/bills');
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
return { success: false, error: '' };
}
return result;
} else {
return await updateBill(String(billId), billData);
const result = await updateBillRaw(String(billId), apiPayload);
return result;
}
} finally {
setIsSubmitting(false);
}
}, [formData, clients, isNewMode, billId, validateForm, router]);
}, [formData, clients, conditions.isReceived, isNewMode, billId, validateForm, router]);
// ===== 삭제 핸들러 (결과만 반환, 토스트/리다이렉트는 IntegratedDetailTemplate에 위임) =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
setIsDeleting(true);
try {
@@ -223,284 +153,91 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
}
}, [billId]);
// ===== 차수 관리 핸들러 =====
const handleAddInstallment = useCallback(() => {
const newInstallment: InstallmentRecord = {
id: `inst-${Date.now()}`,
date: '',
amount: 0,
note: '',
};
setFormData(prev => ({
...prev,
installments: [...prev.installments, newInstallment],
}));
}, []);
const handleRemoveInstallment = useCallback((id: string) => {
setFormData(prev => ({
...prev,
installments: prev.installments.filter(inst => inst.id !== id),
}));
}, []);
const handleUpdateInstallment = useCallback((
id: string,
field: keyof InstallmentRecord,
value: string | number
) => {
setFormData(prev => ({
...prev,
installments: prev.installments.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
),
}));
}, []);
// ===== 상태 옵션 (구분에 따라 변경) =====
const statusOptions = useMemo(
() => getBillStatusOptions(formData.billType),
[formData.billType]
);
// ===== 폼 콘텐츠 렌더링 =====
// 폼 콘텐츠 렌더링
const renderFormContent = () => (
<>
{/* 기본 정보 섹션 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 어음번호 */}
<div className="space-y-2">
<Label htmlFor="billNumber">
<span className="text-red-500">*</span>
</Label>
<Input
id="billNumber"
value={formData.billNumber}
onChange={(e) => updateField('billNumber', e.target.value)}
placeholder="어음번호를 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 1. 기본 정보 */}
<BasicInfoSection
formData={formData}
updateField={updateField}
isViewMode={isViewMode}
clients={clients}
conditions={conditions}
onInstrumentTypeChange={handleInstrumentTypeChange}
onDirectionChange={handleDirectionChange}
/>
{/* 구분 */}
<div className="space-y-2">
<Label htmlFor="billType">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.billType}
onValueChange={(v) => updateField('billType', v as BillType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{BILL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 2. 전자어음 정보 */}
{conditions.showElectronic && (
<ElectronicBillSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="vendorId">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.vendorId}
onValueChange={(v) => updateField('vendorId', v)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 3. 환어음 정보 */}
{conditions.showExchangeBill && (
<ExchangeBillSection
formData={formData}
updateField={updateField}
isViewMode={isViewMode}
showAcceptanceRefusal={conditions.showAcceptanceRefusal}
/>
)}
{/* 금액 */}
<div className="space-y-2">
<Label htmlFor="amount">
<span className="text-red-500">*</span>
</Label>
<CurrencyInput
id="amount"
value={formData.amount}
onChange={(value) => updateField('amount', value ?? 0)}
placeholder="금액을 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 4. 할인 정보 */}
{conditions.showDiscount && (
<DiscountInfoSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 발행일 */}
<div className="space-y-2">
<Label htmlFor="issueDate">
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={formData.issueDate}
onChange={(date) => updateField('issueDate', date)}
disabled={isViewMode}
/>
</div>
{/* 5. 배서양도 정보 */}
{conditions.showEndorsement && (
<EndorsementSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 만기일 */}
<div className="space-y-2">
<Label htmlFor="maturityDate">
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={formData.maturityDate}
onChange={(date) => updateField('maturityDate', date)}
disabled={isViewMode}
/>
</div>
{/* 6. 추심 정보 */}
{conditions.showCollection && (
<CollectionSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 상태 */}
<div className="space-y-2">
<Label htmlFor="status">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.status}
onValueChange={(v) => updateField('status', v as BillStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 7. 이력 관리 (받을어음만) */}
{conditions.isReceived && (
<HistorySection
formData={formData}
updateField={updateField}
isViewMode={isViewMode}
isElectronic={conditions.isElectronic}
maxSplitCount={conditions.maxSplitCount}
onAddInstallment={addInstallment}
onRemoveInstallment={removeInstallment}
onUpdateInstallment={updateInstallment}
/>
)}
{/* 비고 */}
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Input
id="note"
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
placeholder="비고를 입력해주세요"
disabled={isViewMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 8. 개서 정보 */}
{conditions.showRenewal && (
<RenewalSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 차수 관리 섹션 */}
<Card className="mb-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<span className="text-red-500">*</span>
</CardTitle>
{!isViewMode && (
<Button
variant="outline"
size="sm"
onClick={handleAddInstallment}
className="text-orange-500 border-orange-300 hover:bg-orange-50"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
{!isViewMode && <TableHead className="w-[60px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{formData.installments.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
formData.installments.map((inst, index) => (
<TableRow key={inst.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<DatePicker
value={inst.date}
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
disabled={isViewMode}
/>
</TableCell>
<TableCell>
<CurrencyInput
value={inst.amount}
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
<TableCell>
<Input
value={inst.note}
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
{!isViewMode && (
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleRemoveInstallment(inst.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* 9. 소구 정보 */}
{conditions.showRecourse && (
<RecourseSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 10. 환매 정보 */}
{conditions.showBuyback && (
<BuybackSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
{/* 11. 부도 정보 */}
{conditions.showDishonored && (
<DishonoredSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
)}
</>
);
// ===== 템플릿 모드 및 동적 설정 =====
// 템플릿 설정
const templateMode = isNewMode ? 'create' : mode;
const dynamicConfig = {
...billConfig,
title: isViewMode ? '어음 상세' : '어음',
title: isViewMode ? '어음/수표 상세' : '어음/수표',
actions: {
...billConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',

View File

@@ -10,7 +10,7 @@
* - tableHeaderActions: 거래처, 구분, 상태 필터
*/
import { useState, useMemo, useCallback } from 'react';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { formatNumber } from '@/lib/utils/amount';
import { useDateRange } from '@/hooks';
@@ -32,8 +32,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import {
UniversalListPage,
type UniversalListConfig,
@@ -148,6 +146,16 @@ export function BillManagementClient({
}
}, [searchQuery, billTypeFilter, statusFilter, vendorFilter, startDate, endDate, sortOption, itemsPerPage]);
// ===== 필터 변경 시 자동 재조회 =====
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
loadData(1);
}, [loadData]);
// ===== 체크박스 핸들러 =====
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
@@ -348,32 +356,8 @@ export function BillManagementClient({
);
},
// 모바일 필터 설정
filterConfig: [
{
key: 'vendorFilter',
label: '거래처',
type: 'single',
options: vendorOptions.filter(o => o.value !== 'all'),
},
{
key: 'billType',
label: '구분',
type: 'single',
options: BILL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
},
{
key: 'status',
label: '상태',
type: 'single',
options: BILL_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all'),
},
],
initialFilters: {
vendorFilter: vendorFilter,
billType: billTypeFilter,
status: statusFilter,
},
// 모바일 필터 설정 (tableHeaderActions와 중복 방지를 위해 비워둠)
filterConfig: [],
filterTitle: '어음 필터',
// 날짜 선택기
@@ -392,44 +376,12 @@ export function BillManagementClient({
icon: Plus,
},
// 헤더 액션: 수취/발행 라디오 + 상태 선택 + 저장
// 모바일: 라디오/상태필터는 숨기고 저장만 표시 (filterConfig 바텀시트와 중복 방지)
// 데스크톱: 모두 표시
// 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리)
headerActions: () => (
<div className="flex items-center gap-3" style={{ display: 'flex' }}>
<div className="hidden xl:flex items-center gap-3">
<RadioGroup
value={billTypeFilter}
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
className="flex items-center gap-3"
>
<div className="flex items-center space-x-1">
<RadioGroupItem value="received" id="received" />
<Label htmlFor="received" className="cursor-pointer text-sm whitespace-nowrap"></Label>
</div>
<div className="flex items-center space-x-1">
<RadioGroupItem value="issued" id="issued" />
<Label htmlFor="issued" className="cursor-pointer text-sm whitespace-nowrap"></Label>
</div>
</RadioGroup>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-[100px] w-auto">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleSave} size="sm" disabled={isLoading}>
<Save className="h-4 w-4 mr-1" />
</Button>
</div>
<Button onClick={handleSave} size="sm" disabled={isLoading}>
<Save className="h-4 w-4 mr-1" />
</Button>
),
// 테이블 헤더 액션 (필터)
@@ -448,7 +400,7 @@ export function BillManagementClient({
</SelectContent>
</Select>
<Select value={billTypeFilter} onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}>
<Select value={billTypeFilter} onValueChange={setBillTypeFilter}>
<SelectTrigger className="min-w-[100px] w-auto">
<SelectValue placeholder="구분" />
</SelectTrigger>
@@ -461,7 +413,7 @@ export function BillManagementClient({
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); loadData(1); }}>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-[110px] w-auto">
<SelectValue placeholder="보관중" />
</SelectTrigger>

View File

@@ -19,7 +19,8 @@ interface BillSummaryApiData {
// ===== 어음 목록 조회 =====
export async function getBills(params: {
search?: string; billType?: string; status?: string; clientId?: string;
isElectronic?: boolean; issueStartDate?: string; issueEndDate?: string;
isElectronic?: boolean; instrumentType?: string; medium?: string;
issueStartDate?: string; issueEndDate?: string;
maturityStartDate?: string; maturityEndDate?: string;
sortBy?: string; sortDir?: string; perPage?: number; page?: number;
}) {
@@ -30,6 +31,8 @@ export async function getBills(params: {
status: params.status && params.status !== 'all' ? params.status : undefined,
client_id: params.clientId,
is_electronic: params.isElectronic,
instrument_type: params.instrumentType && params.instrumentType !== 'all' ? params.instrumentType : undefined,
medium: params.medium && params.medium !== 'all' ? params.medium : undefined,
issue_start_date: params.issueStartDate,
issue_end_date: params.issueEndDate,
maturity_start_date: params.maturityStartDate,
@@ -124,6 +127,34 @@ export async function getBillSummary(params: {
});
}
// ===== V8: 어음 상세 조회 (BillApiData 그대로 반환) =====
export async function getBillRaw(id: string): Promise<ActionResult<BillApiData>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/bills/${id}`),
errorMessage: '어음 조회에 실패했습니다.',
});
}
// ===== V8: 어음 등록 (raw payload) =====
export async function createBillRaw(data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
return executeServerAction({
url: buildApiUrl('/api/v1/bills'),
method: 'POST',
body: data,
errorMessage: '어음 등록에 실패했습니다.',
});
}
// ===== V8: 어음 수정 (raw payload) =====
export async function updateBillRaw(id: string, data: Record<string, unknown>): Promise<ActionResult<BillApiData>> {
return executeServerAction({
url: buildApiUrl(`/api/v1/bills/${id}`),
method: 'PUT',
body: data,
errorMessage: '어음 수정에 실패했습니다.',
});
}
// ===== 거래처 목록 조회 =====
export async function getClients(): Promise<ActionResult<{ id: number; name: string }[]>> {
return executeServerAction({

View File

@@ -9,8 +9,8 @@ import type { DetailConfig } from '@/components/templates/IntegratedDetailTempla
* (차수 관리 테이블 등 특수 기능 유지)
*/
export const billConfig: DetailConfig = {
title: '어음 상세',
description: '어음 및 수취어음 상세 현황을 관리합니다',
title: '어음/수표 상세',
description: '어음/수표 상세 현황을 관리합니다',
icon: FileText,
basePath: '/accounting/bills',
fields: [], // renderView/renderForm 사용으로 필드 정의 불필요
@@ -25,8 +25,8 @@ export const billConfig: DetailConfig = {
submitLabel: '저장',
cancelLabel: '취소',
deleteConfirmMessage: {
title: '어음 삭제',
description: '이 어음 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
title: '어음/수표 삭제',
description: '이 어음/수표를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
},
},
};

View File

@@ -0,0 +1,178 @@
// ===== 증권종류 =====
export const INSTRUMENT_TYPE_OPTIONS = [
{ value: 'promissory', label: '약속어음' },
{ value: 'exchange', label: '환어음' },
{ value: 'cashierCheck', label: '자기앞수표 (가게수표)' },
{ value: 'currentCheck', label: '당좌수표' },
] as const;
// ===== 거래방향 =====
export const DIRECTION_OPTIONS = [
{ value: 'received', label: '수취 (받을어음)' },
{ value: 'issued', label: '발행 (지급어음)' },
] as const;
// ===== 전자/지류 =====
export const MEDIUM_OPTIONS = [
{ value: 'electronic', label: '전자' },
{ value: 'paper', label: '지류 (종이)' },
] as const;
// ===== 배서 여부 =====
export const ENDORSEMENT_OPTIONS = [
{ value: 'endorsable', label: '배서 가능' },
{ value: 'nonEndorsable', label: '배서 불가 (배서금지어음)' },
] as const;
// ===== 어음구분 =====
export const BILL_CATEGORY_OPTIONS = [
{ value: 'commercial', label: '상업어음 (매출채권)' },
{ value: 'other', label: '기타어음 (대여금/미수금)' },
] as const;
// ===== 받을어음 - 결제상태 (어음용) =====
export const RECEIVED_STATUS_OPTIONS = [
{ value: 'stored', label: '보관중' },
{ value: 'endorsed', label: '배서양도' },
{ value: 'discounted', label: '할인' },
{ value: 'collected', label: '추심' },
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
{ value: 'maturityDeposit', label: '만기입금' },
{ value: 'paymentComplete', label: '결제완료' },
{ value: 'renewed', label: '개서 (만기연장)' },
{ value: 'recourse', label: '소구 (배서어음 상환)' },
{ value: 'buyback', label: '환매 (할인어음 부도)' },
{ value: 'dishonored', label: '부도' },
] as const;
// ===== 받을수표 - 결제상태 (수표용) =====
export const RECEIVED_CHECK_STATUS_OPTIONS = [
{ value: 'stored', label: '보관중' },
{ value: 'endorsed', label: '배서양도' },
{ value: 'collected', label: '추심' },
{ value: 'deposited', label: '추심입금' },
{ value: 'paymentComplete', label: '결제완료 (제시입금)' },
{ value: 'recourse', label: '소구 (수표법 제39조)' },
{ value: 'dishonored', label: '부도' },
] as const;
// ===== 지급어음 - 지급상태 =====
export const ISSUED_STATUS_OPTIONS = [
{ value: 'stored', label: '보관중' },
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
{ value: 'maturityPayment', label: '만기결제' },
{ value: 'paid', label: '결제완료' },
{ value: 'renewed', label: '개서 (만기연장)' },
{ value: 'dishonored', label: '부도' },
] as const;
// ===== 지급수표 - 지급상태 =====
export const ISSUED_CHECK_STATUS_OPTIONS = [
{ value: 'stored', label: '미결제' },
{ value: 'paid', label: '결제완료 (제시출금)' },
{ value: 'dishonored', label: '부도' },
] as const;
// ===== 결제방법 =====
export const PAYMENT_METHOD_OPTIONS = [
{ value: 'autoTransfer', label: '만기자동이체' },
{ value: 'currentAccount', label: '당좌결제' },
{ value: 'other', label: '기타' },
] as const;
// ===== 부도사유 =====
export const DISHONOR_REASON_OPTIONS = [
{ value: 'insufficient_funds', label: '자금부족 (1호 부도)' },
{ value: 'trading_suspension', label: '거래정지처분 (2호 부도)' },
{ value: 'formal_defect', label: '형식불비' },
{ value: 'signature_mismatch', label: '서명/인감 불일치' },
{ value: 'expired', label: '제시기간 경과' },
{ value: 'other', label: '기타' },
] as const;
// ===== 이력 처리구분 =====
export const HISTORY_TYPE_OPTIONS = [
{ value: 'received', label: '수취' },
{ value: 'endorsement', label: '배서양도' },
{ value: 'splitEndorsement', label: '분할배서' },
{ value: 'collection', label: '추심의뢰' },
{ value: 'collectionDeposit', label: '추심입금' },
{ value: 'discount', label: '할인' },
{ value: 'maturityDeposit', label: '만기입금' },
{ value: 'dishonored', label: '부도' },
{ value: 'recourse', label: '소구' },
{ value: 'buyback', label: '환매' },
{ value: 'renewal', label: '개서' },
{ value: 'other', label: '기타' },
] as const;
// ===== 배서차수 (지류: 4차, 전자: 20차) =====
export const ENDORSEMENT_ORDER_PAPER = [
{ value: '1', label: '1차 (발행인 직접수취)' },
{ value: '2', label: '2차 (1개 업체 경유)' },
{ value: '3', label: '3차 (2개 업체 경유)' },
{ value: '4', label: '4차 (3개 업체 경유)' },
] as const;
export const ENDORSEMENT_ORDER_ELECTRONIC = Array.from({ length: 20 }, (_, i) => ({
value: String(i + 1),
label: i === 0 ? '1차 (발행인 직접수취)' : `${i + 1}차 (${i}개 업체 경유)`,
}));
// ===== 보관장소 =====
export const STORAGE_OPTIONS = [
{ value: 'safe', label: '금고' },
{ value: 'bank', label: '은행 보관' },
{ value: 'other', label: '기타' },
] as const;
// ===== 지급장소 (어음법 제75조) =====
export const PAYMENT_PLACE_OPTIONS = [
{ value: 'issuerBank', label: '발행은행 본점' },
{ value: 'issuerBankBranch', label: '발행은행 지점' },
{ value: 'payerAddress', label: '지급인 주소지' },
{ value: 'designatedBank', label: '지정 은행' },
{ value: 'other', label: '기타' },
] as const;
// ===== 수표 지급장소 (수표법 제3조: 은행만) =====
export const PAYMENT_PLACE_CHECK_OPTIONS = [
{ value: 'issuerBank', label: '발행은행 본점' },
{ value: 'issuerBankBranch', label: '발행은행 지점' },
{ value: 'designatedBank', label: '지정 은행' },
] as const;
// ===== 추심결과 =====
export const COLLECTION_RESULT_OPTIONS = [
{ value: 'success', label: '추심 성공 (입금완료)' },
{ value: 'partial', label: '일부 입금' },
{ value: 'failed', label: '추심 실패 (부도)' },
{ value: 'pending', label: '추심 진행중' },
] as const;
// ===== 소구사유 =====
export const RECOURSE_REASON_OPTIONS = [
{ value: 'endorsedDishonor', label: '배서양도 어음 부도' },
{ value: 'discountDishonor', label: '할인 어음 부도 (환매)' },
{ value: 'other', label: '기타' },
] as const;
// ===== 인수거절 사유 =====
export const ACCEPTANCE_REFUSAL_REASON_OPTIONS = [
{ value: 'financialDifficulty', label: '자금 사정 곤란' },
{ value: 'disputeOfClaim', label: '채권 분쟁' },
{ value: 'amountDispute', label: '금액 이의' },
{ value: 'other', label: '기타' },
] as const;
// ===== 개서 사유 =====
export const RENEWAL_REASON_OPTIONS = [
{ value: 'maturityExtension', label: '만기일 연장' },
{ value: 'amountChange', label: '금액 변경' },
{ value: 'conditionChange', label: '조건 변경' },
{ value: 'other', label: '기타' },
] as const;
// ===== 수표 관련 유효 상태 목록 (증권종류 전환 시 검증용) =====
export const VALID_CHECK_RECEIVED_STATUSES = ['stored', 'endorsed', 'collected', 'deposited', 'paymentComplete', 'recourse', 'dishonored'];
export const VALID_CHECK_ISSUED_STATUSES = ['stored', 'paid', 'dishonored'];

View File

@@ -0,0 +1,69 @@
'use client';
import { useMemo } from 'react';
import type { BillFormData } from '../types';
import {
RECEIVED_STATUS_OPTIONS,
RECEIVED_CHECK_STATUS_OPTIONS,
ISSUED_STATUS_OPTIONS,
ISSUED_CHECK_STATUS_OPTIONS,
PAYMENT_PLACE_OPTIONS,
PAYMENT_PLACE_CHECK_OPTIONS,
} from '../constants';
export function useBillConditions(formData: BillFormData) {
return useMemo(() => {
const isReceived = formData.direction === 'received';
const isIssued = formData.direction === 'issued';
const isCheck = formData.instrumentType === 'cashierCheck' || formData.instrumentType === 'currentCheck';
const isBill = !isCheck;
const canBeElectronic = formData.instrumentType === 'promissory';
const isElectronic = formData.medium === 'electronic';
const currentStatus = isReceived ? formData.receivedStatus : formData.issuedStatus;
// 조건부 섹션 표시 플래그
const showElectronic = isElectronic;
const showExchangeBill = formData.instrumentType === 'exchange';
const showDiscount = isReceived && formData.isDiscounted && isBill;
const showEndorsement = isReceived && formData.receivedStatus === 'endorsed';
const showCollection = isReceived && formData.receivedStatus === 'collected';
const showDishonored = currentStatus === 'dishonored';
const showRenewal = currentStatus === 'renewed' && isBill;
const showRecourse = isReceived && formData.receivedStatus === 'recourse';
const showBuyback = isReceived && formData.receivedStatus === 'buyback' && isBill;
const showAcceptanceRefusal = showExchangeBill && formData.acceptanceStatus === 'refused';
// 현재 증권종류에 맞는 옵션 목록
const receivedStatusOptions = isCheck ? RECEIVED_CHECK_STATUS_OPTIONS : RECEIVED_STATUS_OPTIONS;
const issuedStatusOptions = isCheck ? ISSUED_CHECK_STATUS_OPTIONS : ISSUED_STATUS_OPTIONS;
const paymentPlaceOptions = isCheck ? PAYMENT_PLACE_CHECK_OPTIONS : PAYMENT_PLACE_OPTIONS;
// 분할배서 최대 횟수
const maxSplitCount = isElectronic ? 4 : 10;
return {
isReceived,
isIssued,
isCheck,
isBill,
canBeElectronic,
isElectronic,
currentStatus,
showElectronic,
showExchangeBill,
showDiscount,
showEndorsement,
showCollection,
showDishonored,
showRenewal,
showRecourse,
showBuyback,
showAcceptanceRefusal,
receivedStatusOptions,
issuedStatusOptions,
paymentPlaceOptions,
maxSplitCount,
};
}, [formData.direction, formData.instrumentType, formData.medium, formData.isDiscounted, formData.receivedStatus, formData.issuedStatus, formData.acceptanceStatus]);
}

View File

@@ -0,0 +1,103 @@
'use client';
import { useState, useCallback } from 'react';
import type { BillFormData } from '../types';
import { INITIAL_BILL_FORM_DATA } from '../types';
import {
VALID_CHECK_RECEIVED_STATUSES,
VALID_CHECK_ISSUED_STATUSES,
} from '../constants';
export function useBillForm(initialData?: Partial<BillFormData>) {
const [formData, setFormData] = useState<BillFormData>({
...INITIAL_BILL_FORM_DATA,
...initialData,
});
const updateField = useCallback(<K extends keyof BillFormData>(field: K, value: BillFormData[K]) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 증권종류 변경 시 연관 필드 초기화
const handleInstrumentTypeChange = useCallback((newType: string) => {
setFormData(prev => {
const next = { ...prev, instrumentType: newType as BillFormData['instrumentType'] };
const isCheckType = newType === 'cashierCheck' || newType === 'currentCheck';
// 약속어음 외에는 전자 불가 → 지류로 리셋
if (newType !== 'promissory' && prev.medium === 'electronic') {
next.medium = 'paper';
}
// 수표 전환 시: 만기일, 할인, 관련 필드 리셋
if (isCheckType) {
next.maturityDate = '';
next.isDiscounted = false;
if (!VALID_CHECK_RECEIVED_STATUSES.includes(prev.receivedStatus)) {
next.receivedStatus = 'stored';
}
if (!VALID_CHECK_ISSUED_STATUSES.includes(prev.issuedStatus)) {
next.issuedStatus = 'stored';
}
if (prev.paymentPlace === 'payerAddress' || prev.paymentPlace === 'other') {
next.paymentPlace = '';
}
}
return next;
});
}, []);
// 거래방향 변경 시 상태 초기화
const handleDirectionChange = useCallback((newDirection: string) => {
setFormData(prev => ({
...prev,
direction: newDirection as BillFormData['direction'],
receivedStatus: 'stored',
issuedStatus: 'stored',
}));
}, []);
// 이력 관리
const addInstallment = useCallback(() => {
setFormData(prev => ({
...prev,
installments: [
...prev.installments,
{ id: `inst-${Date.now()}`, date: '', type: 'other', amount: 0, counterparty: '', note: '' },
],
}));
}, []);
const removeInstallment = useCallback((id: string) => {
setFormData(prev => ({
...prev,
installments: prev.installments.filter(inst => inst.id !== id),
}));
}, []);
const updateInstallment = useCallback((id: string, field: string, value: string | number) => {
setFormData(prev => ({
...prev,
installments: prev.installments.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
),
}));
}, []);
// 폼 전체 덮어쓰기 (API 데이터 로드 시)
const setFormDataFull = useCallback((data: BillFormData) => {
setFormData(data);
}, []);
return {
formData,
updateField,
handleInstrumentTypeChange,
handleDirectionChange,
addInstallment,
removeInstallment,
updateInstallment,
setFormDataFull,
};
}

View File

@@ -0,0 +1,288 @@
'use client';
import { useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import {
INSTRUMENT_TYPE_OPTIONS,
DIRECTION_OPTIONS,
MEDIUM_OPTIONS,
ENDORSEMENT_OPTIONS,
BILL_CATEGORY_OPTIONS,
STORAGE_OPTIONS,
PAYMENT_METHOD_OPTIONS,
ENDORSEMENT_ORDER_PAPER,
ENDORSEMENT_ORDER_ELECTRONIC,
} from '../constants';
interface BasicInfoSectionProps extends SectionProps {
clients: { id: string; name: string }[];
conditions: {
isReceived: boolean;
isIssued: boolean;
isCheck: boolean;
isBill: boolean;
canBeElectronic: boolean;
isElectronic: boolean;
receivedStatusOptions: readonly { value: string; label: string }[];
issuedStatusOptions: readonly { value: string; label: string }[];
paymentPlaceOptions: readonly { value: string; label: string }[];
};
onInstrumentTypeChange: (v: string) => void;
onDirectionChange: (v: string) => void;
}
export function BasicInfoSection({
formData, updateField, isViewMode, clients, conditions, onInstrumentTypeChange, onDirectionChange,
}: BasicInfoSectionProps) {
const {
isReceived, isIssued, isCheck, isBill, canBeElectronic, isElectronic,
receivedStatusOptions, issuedStatusOptions, paymentPlaceOptions,
} = conditions;
const endorsementOrderOptions = useMemo(
() => isElectronic ? ENDORSEMENT_ORDER_ELECTRONIC : [...ENDORSEMENT_ORDER_PAPER],
[isElectronic]
);
return (
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{/* 어음번호 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input value={formData.billNumber} onChange={(e) => updateField('billNumber', e.target.value)} placeholder="자동생성 또는 직접입력" disabled={isViewMode} />
</div>
{/* 증권종류 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.instrumentType} onValueChange={onInstrumentTypeChange} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{INSTRUMENT_TYPE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 거래방향 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.direction} onValueChange={onDirectionChange} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{DIRECTION_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 전자/지류 */}
<div className="space-y-2">
<Label>/ <span className="text-red-500">*</span>
{!canBeElectronic && <span className="text-xs text-muted-foreground ml-1">(전자어음법: 약속어음만 )</span>}
</Label>
<Select value={formData.medium} onValueChange={(v) => updateField('medium', v as 'electronic' | 'paper')} disabled={isViewMode || !canBeElectronic}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{MEDIUM_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label>{isReceived ? '거래처 (발행인)' : '수취인 (거래처)'} <span className="text-red-500">*</span></Label>
<Select
value={isReceived ? formData.vendor : formData.payee}
onValueChange={(v) => updateField(isReceived ? 'vendor' : 'payee', v)}
disabled={isViewMode}
>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{clients.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 금액 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<CurrencyInput value={formData.amount} onChange={(v) => updateField('amount', v ?? 0)} disabled={isViewMode} />
</div>
{/* 발행일 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.issueDate} onChange={(d) => updateField('issueDate', d)} disabled={isViewMode} />
</div>
{/* 만기일 (수표는 일람출급이므로 없음) */}
{isBill && (
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.maturityDate} onChange={(d) => updateField('maturityDate', d)} disabled={isViewMode} />
</div>
)}
{/* 은행 */}
<div className="space-y-2">
<Label>{isReceived ? '발행은행' : '결제은행'}</Label>
<Input
value={isReceived ? formData.issuerBank : formData.settlementBank}
onChange={(e) => updateField(isReceived ? 'issuerBank' : 'settlementBank', e.target.value)}
placeholder={isReceived ? '예: 국민은행' : '예: 신한은행'}
disabled={isViewMode}
/>
</div>
{/* 지급장소 */}
<div className="space-y-2">
<Label> <span className="text-red-500">*</span>
{isCheck && <span className="text-xs text-muted-foreground ml-1">(수표: 은행만)</span>}
</Label>
<Select value={formData.paymentPlace} onValueChange={(v) => updateField('paymentPlace', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{paymentPlaceOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 지급장소 상세 */}
{formData.paymentPlace === 'other' && (
<div className="space-y-2">
<Label> </Label>
<Input value={formData.paymentPlaceDetail} onChange={(e) => updateField('paymentPlaceDetail', e.target.value)} placeholder="지급장소를 직접 입력" disabled={isViewMode} />
</div>
)}
{/* 어음구분 (어음만) */}
{isBill && (
<div className="space-y-2">
<Label></Label>
<Select value={formData.billCategory} onValueChange={(v) => updateField('billCategory', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{BILL_CATEGORY_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
)}
{/* ===== 받을어음 전용 필드 ===== */}
{isReceived && (
<>
<div className="space-y-2">
<Label> </Label>
<Select value={formData.endorsement} onValueChange={(v) => updateField('endorsement', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{ENDORSEMENT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={formData.endorsementOrder} onValueChange={(v) => updateField('endorsementOrder', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{endorsementOrderOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={formData.storagePlace} onValueChange={(v) => updateField('storagePlace', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{STORAGE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.receivedStatus} onValueChange={(v) => updateField('receivedStatus', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{receivedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
{/* 할인여부 (수표 제외) */}
{isBill && (
<div className="space-y-2">
<Label></Label>
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
<Switch checked={formData.isDiscounted} onCheckedChange={(c) => {
updateField('isDiscounted', c);
if (c) updateField('receivedStatus', 'discounted');
}} disabled={isViewMode} />
<span className="text-sm">{formData.isDiscounted ? '할인 적용' : '미적용'}</span>
</div>
</div>
)}
</>
)}
{/* ===== 지급어음 전용 필드 ===== */}
{isIssued && (
<>
<div className="space-y-2">
<Label></Label>
<Select value={formData.paymentMethod} onValueChange={(v) => updateField('paymentMethod', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{PAYMENT_METHOD_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.issuedStatus} onValueChange={(v) => updateField('issuedStatus', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{issuedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker value={formData.actualPaymentDate} onChange={(d) => updateField('actualPaymentDate', d)} disabled={isViewMode} />
</div>
</>
)}
{/* 입출금 계좌 */}
<div className="space-y-2">
<Label>/ </Label>
<Input value={formData.bankAccountInfo} onChange={(e) => updateField('bankAccountInfo', e.target.value)} placeholder="계좌 정보" disabled={isViewMode} />
</div>
{/* 비고 */}
<div className="space-y-2 lg:col-span-2">
<Label></Label>
<Input value={formData.note} onChange={(e) => updateField('note', e.target.value)} placeholder="비고를 입력해주세요" disabled={isViewMode} />
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { SectionProps } from './types';
export function BuybackSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-orange-200 bg-orange-50/30">
<CardHeader>
<CardTitle className="text-lg text-orange-700"> </CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-4"> () </p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.buybackDate} onChange={(d) => updateField('buybackDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<CurrencyInput value={formData.buybackAmount} onChange={(v) => updateField('buybackAmount', v ?? 0)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> </Label>
<Input value={formData.buybackBank} onChange={(e) => updateField('buybackBank', e.target.value)} placeholder="환매 청구 금융기관" disabled={isViewMode} />
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { COLLECTION_RESULT_OPTIONS } from '../constants';
export function CollectionSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* 추심 의뢰 */}
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide"> </p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Input value={formData.collectionBank} onChange={(e) => updateField('collectionBank', e.target.value)} placeholder="추심 의뢰 은행" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.collectionRequestDate} onChange={(d) => updateField('collectionRequestDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<CurrencyInput value={formData.collectionFee} onChange={(v) => updateField('collectionFee', v ?? 0)} disabled={isViewMode} />
</div>
</div>
{/* 추심 결과 */}
<div className="border-t pt-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4"> </p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label></Label>
<Select value={formData.collectionResult} onValueChange={(v) => updateField('collectionResult', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{COLLECTION_RESULT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker value={formData.collectionCompleteDate} onChange={(d) => updateField('collectionCompleteDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<DatePicker value={formData.collectionDepositDate} onChange={(d) => updateField('collectionDepositDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> ( )</Label>
<CurrencyInput value={formData.collectionDepositAmount} onChange={(v) => updateField('collectionDepositAmount', v ?? 0)} disabled={isViewMode} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import type { SectionProps } from './types';
export function DiscountInfoSection({ formData, updateField, isViewMode }: SectionProps) {
const calcNetReceived = useMemo(() => {
if (formData.amount > 0 && formData.discountAmount > 0) return formData.amount - formData.discountAmount;
return 0;
}, [formData.amount, formData.discountAmount]);
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.discountDate} onChange={(d) => updateField('discountDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> () <span className="text-red-500">*</span></Label>
<Input value={formData.discountBank} onChange={(e) => updateField('discountBank', e.target.value)} placeholder="예: 국민은행 강남지점" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> (%)</Label>
<Input type="number" step="0.01" min={0} max={100} value={formData.discountRate || ''} onChange={(e) => {
const rate = parseFloat(e.target.value) || 0;
updateField('discountRate', rate);
if (formData.amount > 0 && rate > 0) updateField('discountAmount', Math.round(formData.amount * rate / 100));
}} placeholder="예: 3.5" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<CurrencyInput value={formData.discountAmount} onChange={(v) => updateField('discountAmount', v ?? 0)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> ()</Label>
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm font-semibold">
{calcNetReceived > 0
? <span className="text-green-700"> {calcNetReceived.toLocaleString()}</span>
: <span className="text-gray-400"> - </span>}
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,88 @@
'use client';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { DISHONOR_REASON_OPTIONS } from '../constants';
export function DishonoredSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-red-200 bg-red-50/30">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2 text-red-700">
<Badge variant="destructive" className="text-xs"></Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.dishonoredDate} onChange={(d) => {
updateField('dishonoredDate', d);
if (d) {
const dt = new Date(d);
dt.setDate(dt.getDate() + 6);
updateField('recourseNoticeDeadline', dt.toISOString().split('T')[0]);
}
}} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.dishonoredReason} onValueChange={(v) => updateField('dishonoredReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
<SelectContent>
{DISHONOR_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
{/* 법적 프로세스 */}
<div className="border-t pt-4">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4"> ( 44·45)</p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label> </Label>
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
<Switch checked={formData.hasProtest} onCheckedChange={(c) => updateField('hasProtest', c)} disabled={isViewMode} />
<span className="text-sm">{formData.hasProtest ? '작성 완료' : '미작성'}</span>
</div>
</div>
{formData.hasProtest && (
<div className="space-y-2">
<Label> </Label>
<DatePicker value={formData.protestDate} onChange={(d) => updateField('protestDate', d)} disabled={isViewMode} />
</div>
)}
<div className="space-y-2">
<Label> </Label>
<DatePicker value={formData.recourseNoticeDate} onChange={(d) => updateField('recourseNoticeDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> (자동: 부도일+4)</Label>
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm">
{formData.recourseNoticeDeadline ? (
<span className={
formData.recourseNoticeDate && formData.recourseNoticeDate <= formData.recourseNoticeDeadline
? 'text-green-700' : 'text-red-600 font-medium'
}>
{formData.recourseNoticeDeadline}
{formData.recourseNoticeDate && formData.recourseNoticeDate > formData.recourseNoticeDeadline && ' (기한 초과!)'}
</span>
) : <span className="text-gray-400"> </span>}
</div>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
export function ElectronicBillSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label> </Label>
<Input value={formData.electronicBillNo} onChange={(e) => updateField('electronicBillNo', e.target.value)} placeholder="전자어음시스템 발급번호" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<Select value={formData.registrationOrg} onValueChange={(v) => updateField('registrationOrg', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="kftc"></SelectItem>
<SelectItem value="bank"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
export function EndorsementSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.endorsementDate} onChange={(d) => updateField('endorsementDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> () <span className="text-red-500">*</span></Label>
<Input value={formData.endorsee} onChange={(e) => updateField('endorsee', e.target.value)} placeholder="어음을 넘겨받는 자" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> </Label>
<Select value={formData.endorsementReason} onValueChange={(v) => updateField('endorsementReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="payment"></SelectItem>
<SelectItem value="guarantee"></SelectItem>
<SelectItem value="collection"></SelectItem>
<SelectItem value="other"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { AlertTriangle } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { ACCEPTANCE_REFUSAL_REASON_OPTIONS } from '../constants';
interface ExchangeBillSectionProps extends SectionProps {
showAcceptanceRefusal: boolean;
}
export function ExchangeBillSection({ formData, updateField, isViewMode, showAcceptanceRefusal }: ExchangeBillSectionProps) {
return (
<Card className="mb-6 border-purple-200">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> (Drawee) <span className="text-red-500">*</span></Label>
<Input value={formData.drawee} onChange={(e) => updateField('drawee', e.target.value)} placeholder="지급 의무자" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> </Label>
<Select value={formData.acceptanceStatus} onValueChange={(v) => updateField('acceptanceStatus', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
<SelectItem value="accepted"> </SelectItem>
<SelectItem value="pending"> </SelectItem>
<SelectItem value="refused"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>{formData.acceptanceStatus === 'refused' ? '인수거절일' : '인수일자'}</Label>
<DatePicker
value={formData.acceptanceStatus === 'refused' ? formData.acceptanceRefusalDate : formData.acceptanceDate}
onChange={(d) => updateField(formData.acceptanceStatus === 'refused' ? 'acceptanceRefusalDate' : 'acceptanceDate', d)}
disabled={isViewMode}
/>
</div>
</div>
{showAcceptanceRefusal && (
<div className="border-t pt-4">
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
<span> ( 43). .</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label> </Label>
<Select value={formData.acceptanceRefusalReason} onValueChange={(v) => updateField('acceptanceRefusalReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{ACCEPTANCE_REFUSAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,150 @@
'use client';
import { useMemo } from 'react';
import { Plus, Trash2, AlertTriangle } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import type { BillFormData } from '../types';
import { HISTORY_TYPE_OPTIONS } from '../constants';
interface HistorySectionProps {
formData: BillFormData;
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
isViewMode: boolean;
isElectronic: boolean;
maxSplitCount: number;
onAddInstallment: () => void;
onRemoveInstallment: (id: string) => void;
onUpdateInstallment: (id: string, field: string, value: string | number) => void;
}
export function HistorySection({
formData, updateField, isViewMode, isElectronic, maxSplitCount,
onAddInstallment, onRemoveInstallment, onUpdateInstallment,
}: HistorySectionProps) {
const splitEndorsementStats = useMemo(() => {
const splits = formData.installments.filter(inst => inst.type === 'splitEndorsement');
const totalAmount = splits.reduce((sum, inst) => sum + inst.amount, 0);
return { count: splits.length, totalAmount, remaining: formData.amount - totalAmount };
}, [formData.installments, formData.amount]);
return (
<Card className="mb-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg"> </CardTitle>
{!isViewMode && (
<Button variant="outline" size="sm" onClick={onAddInstallment} className="text-orange-500 border-orange-300 hover:bg-orange-50">
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* 분할배서 토글 */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<Switch checked={formData.isSplit} onCheckedChange={(c) => updateField('isSplit', c)} disabled={isViewMode} />
<Label> </Label>
{formData.isSplit && (
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
{maxSplitCount}
</Badge>
)}
</div>
{formData.isSplit && isElectronic && (
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
<span> 분할배서: 최초 5 ( 6)</span>
</div>
)}
{formData.isSplit && splitEndorsementStats.count > 0 && (
<div className="flex items-center gap-4 text-sm bg-gray-50 rounded-md px-3 py-2">
<span className="text-muted-foreground">:</span>
<span className="font-semibold"> {formData.amount.toLocaleString()}</span>
<span className="text-muted-foreground">| :</span>
<span className="font-semibold text-blue-600"> {splitEndorsementStats.totalAmount.toLocaleString()}</span>
<span className="text-muted-foreground">| :</span>
<span className={`font-semibold ${splitEndorsementStats.remaining < 0 ? 'text-red-600' : 'text-green-600'}`}>
{splitEndorsementStats.remaining.toLocaleString()}
</span>
{splitEndorsementStats.remaining < 0 && (
<span className="text-red-500 text-xs flex items-center gap-1"><AlertTriangle className="h-3 w-3" /> </span>
)}
</div>
)}
</div>
{/* 이력 테이블 */}
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="min-w-[130px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
<TableHead className="min-w-[120px]"></TableHead>
{!isViewMode && <TableHead className="w-[60px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{formData.installments.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 6 : 7} className="text-center text-gray-500 py-8"> </TableCell>
</TableRow>
) : formData.installments.map((inst, idx) => (
<TableRow key={inst.id} className={inst.type === 'splitEndorsement' ? 'bg-amber-50/50' : ''}>
<TableCell className="text-center">{idx + 1}</TableCell>
<TableCell>
<DatePicker value={inst.date} onChange={(d) => onUpdateInstallment(inst.id, 'date', d)} size="sm" disabled={isViewMode} />
</TableCell>
<TableCell>
<Select value={inst.type} onValueChange={(v) => onUpdateInstallment(inst.id, 'type', v)} disabled={isViewMode}>
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
<SelectContent>
{HISTORY_TYPE_OPTIONS
.filter(o => o.value !== 'splitEndorsement' || formData.isSplit)
.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)
}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<CurrencyInput value={inst.amount} onChange={(v) => onUpdateInstallment(inst.id, 'amount', v ?? 0)} className="h-8 text-sm" disabled={isViewMode} />
</TableCell>
<TableCell>
<Input value={inst.counterparty} onChange={(e) => onUpdateInstallment(inst.id, 'counterparty', e.target.value)} placeholder="거래처/은행" className="h-8 text-sm" disabled={isViewMode} />
</TableCell>
<TableCell>
<Input value={inst.note} onChange={(e) => onUpdateInstallment(inst.id, 'note', e.target.value)} className="h-8 text-sm" disabled={isViewMode} />
</TableCell>
{!isViewMode && (
<TableCell>
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => onRemoveInstallment(inst.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { RECOURSE_REASON_OPTIONS } from '../constants';
export function RecourseSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-orange-200 bg-orange-50/30">
<CardHeader>
<CardTitle className="text-lg text-orange-700"> () </CardTitle>
</CardHeader>
<CardContent>
<p className="text-xs text-muted-foreground mb-4"> </p>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.recourseDate} onChange={(d) => updateField('recourseDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<CurrencyInput value={formData.recourseAmount} onChange={(v) => updateField('recourseAmount', v ?? 0)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> ()</Label>
<Input value={formData.recourseTarget} onChange={(e) => updateField('recourseTarget', e.target.value)} placeholder="피배서인(양수인)명" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<Select value={formData.recourseReason} onValueChange={(v) => updateField('recourseReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
<SelectContent>
{RECOURSE_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,46 @@
'use client';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import type { SectionProps } from './types';
import { RENEWAL_REASON_OPTIONS } from '../constants';
export function RenewalSection({ formData, updateField, isViewMode }: SectionProps) {
return (
<Card className="mb-6 border-amber-200 bg-amber-50/30">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2 text-amber-700">
<Badge variant="outline" className="text-xs border-amber-400 text-amber-700 bg-amber-50"></Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<DatePicker value={formData.renewalDate} onChange={(d) => updateField('renewalDate', d)} disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={formData.renewalNewBillNo} onChange={(e) => updateField('renewalNewBillNo', e.target.value)} placeholder="교체 발행된 신어음 번호" disabled={isViewMode} />
</div>
<div className="space-y-2">
<Label> <span className="text-red-500">*</span></Label>
<Select value={formData.renewalReason} onValueChange={(v) => updateField('renewalReason', v)} disabled={isViewMode}>
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
<SelectContent>
{RENEWAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,11 @@
export { BasicInfoSection } from './BasicInfoSection';
export { ElectronicBillSection } from './ElectronicBillSection';
export { ExchangeBillSection } from './ExchangeBillSection';
export { DiscountInfoSection } from './DiscountInfoSection';
export { EndorsementSection } from './EndorsementSection';
export { CollectionSection } from './CollectionSection';
export { HistorySection } from './HistorySection';
export { RenewalSection } from './RenewalSection';
export { RecourseSection } from './RecourseSection';
export { BuybackSection } from './BuybackSection';
export { DishonoredSection } from './DishonoredSection';

View File

@@ -0,0 +1,7 @@
import type { BillFormData } from '../types';
export interface SectionProps {
formData: BillFormData;
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
isViewMode: boolean;
}

View File

@@ -174,8 +174,10 @@ export function getBillStatusOptions(billType: BillType) {
export interface BillApiInstallment {
id: number;
bill_id: number;
type?: string;
installment_date: string;
amount: string;
counterparty?: string | null;
note: string | null;
created_at: string;
updated_at: string;
@@ -190,7 +192,7 @@ export interface BillApiData {
client_name: string | null;
amount: string;
issue_date: string;
maturity_date: string;
maturity_date: string | null;
status: BillStatus;
reason: string | null;
installment_count: number;
@@ -211,6 +213,58 @@ export interface BillApiData {
account_name: string;
} | null;
installments?: BillApiInstallment[];
// V8 확장 필드
instrument_type?: string;
medium?: string;
bill_category?: string;
electronic_bill_no?: string | null;
registration_org?: string | null;
drawee?: string | null;
acceptance_status?: string | null;
acceptance_date?: string | null;
acceptance_refusal_date?: string | null;
acceptance_refusal_reason?: string | null;
endorsement?: string | null;
endorsement_order?: string | null;
storage_place?: string | null;
issuer_bank?: string | null;
is_discounted?: boolean;
discount_date?: string | null;
discount_bank?: string | null;
discount_rate?: string | null;
discount_amount?: string | null;
endorsement_date?: string | null;
endorsee?: string | null;
endorsement_reason?: string | null;
collection_bank?: string | null;
collection_request_date?: string | null;
collection_fee?: string | null;
collection_complete_date?: string | null;
collection_result?: string | null;
collection_deposit_date?: string | null;
collection_deposit_amount?: string | null;
settlement_bank?: string | null;
payment_method?: string | null;
actual_payment_date?: string | null;
payment_place?: string | null;
payment_place_detail?: string | null;
renewal_date?: string | null;
renewal_new_bill_no?: string | null;
renewal_reason?: string | null;
recourse_date?: string | null;
recourse_amount?: string | null;
recourse_target?: string | null;
recourse_reason?: string | null;
buyback_date?: string | null;
buyback_amount?: string | null;
buyback_bank?: string | null;
dishonored_date?: string | null;
dishonored_reason?: string | null;
has_protest?: boolean;
protest_date?: string | null;
recourse_notice_date?: string | null;
recourse_notice_deadline?: string | null;
is_split?: boolean;
}
export interface BillApiResponse {
@@ -235,7 +289,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
vendorName: apiData.client?.name || apiData.client_name || '',
amount: parseFloat(apiData.amount),
issueDate: apiData.issue_date,
maturityDate: apiData.maturity_date,
maturityDate: apiData.maturity_date || '',
status: apiData.status,
reason: apiData.reason || '',
installmentCount: apiData.installment_count,
@@ -251,7 +305,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
};
}
// ===== Frontend → API 변환 함수 =====
// ===== Frontend → API 변환 함수 (V8 전체 필드 전송) =====
export function transformFrontendToApi(data: Partial<BillRecord>): Record<string, unknown> {
const result: Record<string, unknown> = {};
@@ -261,7 +315,7 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
if (data.vendorName !== undefined) result.client_name = data.vendorName || null;
if (data.amount !== undefined) result.amount = data.amount;
if (data.issueDate !== undefined) result.issue_date = data.issueDate;
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate;
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate || null;
if (data.status !== undefined) result.status = data.status;
if (data.reason !== undefined) result.reason = data.reason || null;
if (data.note !== undefined) result.note = data.note || null;
@@ -275,4 +329,334 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
}
return result;
}
// ===== BillFormData → API payload 변환 (V8 전체 필드 전송) =====
export function transformFormDataToApi(data: BillFormData, vendorName: string): Record<string, unknown> {
const isReceived = data.direction === 'received';
const orNull = (v: string) => v || null;
const orNullNum = (v: number) => v || null;
const orNullDate = (v: string) => v || null;
return {
// 기존 12개 필드
bill_number: data.billNumber,
bill_type: data.direction,
client_id: isReceived ? (data.vendor ? parseInt(data.vendor) : null) : (data.payee ? parseInt(data.payee) : null),
client_name: vendorName || null,
amount: data.amount,
issue_date: data.issueDate,
maturity_date: orNullDate(data.maturityDate),
status: isReceived ? data.receivedStatus : data.issuedStatus,
note: orNull(data.note),
is_electronic: data.medium === 'electronic',
// V8 확장 필드
instrument_type: data.instrumentType,
medium: data.medium,
bill_category: orNull(data.billCategory),
electronic_bill_no: orNull(data.electronicBillNo),
registration_org: orNull(data.registrationOrg),
drawee: orNull(data.drawee),
acceptance_status: orNull(data.acceptanceStatus),
acceptance_date: orNullDate(data.acceptanceDate),
acceptance_refusal_date: orNullDate(data.acceptanceRefusalDate),
acceptance_refusal_reason: orNull(data.acceptanceRefusalReason),
endorsement: orNull(data.endorsement),
endorsement_order: orNull(data.endorsementOrder),
storage_place: orNull(data.storagePlace),
issuer_bank: orNull(data.issuerBank),
is_discounted: data.isDiscounted,
discount_date: orNullDate(data.discountDate),
discount_bank: orNull(data.discountBank),
discount_rate: orNullNum(data.discountRate),
discount_amount: orNullNum(data.discountAmount),
endorsement_date: orNullDate(data.endorsementDate),
endorsee: orNull(data.endorsee),
endorsement_reason: orNull(data.endorsementReason),
collection_bank: orNull(data.collectionBank),
collection_request_date: orNullDate(data.collectionRequestDate),
collection_fee: orNullNum(data.collectionFee),
collection_complete_date: orNullDate(data.collectionCompleteDate),
collection_result: orNull(data.collectionResult),
collection_deposit_date: orNullDate(data.collectionDepositDate),
collection_deposit_amount: orNullNum(data.collectionDepositAmount),
settlement_bank: orNull(data.settlementBank),
payment_method: orNull(data.paymentMethod),
actual_payment_date: orNullDate(data.actualPaymentDate),
payment_place: orNull(data.paymentPlace),
payment_place_detail: orNull(data.paymentPlaceDetail),
renewal_date: orNullDate(data.renewalDate),
renewal_new_bill_no: orNull(data.renewalNewBillNo),
renewal_reason: orNull(data.renewalReason),
recourse_date: orNullDate(data.recourseDate),
recourse_amount: orNullNum(data.recourseAmount),
recourse_target: orNull(data.recourseTarget),
recourse_reason: orNull(data.recourseReason),
buyback_date: orNullDate(data.buybackDate),
buyback_amount: orNullNum(data.buybackAmount),
buyback_bank: orNull(data.buybackBank),
dishonored_date: orNullDate(data.dishonoredDate),
dishonored_reason: orNull(data.dishonoredReason),
has_protest: data.hasProtest,
protest_date: orNullDate(data.protestDate),
recourse_notice_date: orNullDate(data.recourseNoticeDate),
recourse_notice_deadline: orNullDate(data.recourseNoticeDeadline),
is_split: data.isSplit,
// 이력(차수)
installments: data.installments.map(inst => ({
date: inst.date,
type: inst.type || 'other',
amount: inst.amount,
counterparty: orNull(inst.counterparty),
note: orNull(inst.note),
})),
};
}
// =============================================
// V8 확장 타입 (프로토타입 → 실제 페이지 마이그레이션)
// =============================================
// ===== 증권종류 =====
export type InstrumentType = 'promissory' | 'exchange' | 'cashierCheck' | 'currentCheck';
// ===== 거래방향 (Direction = BillType alias) =====
export type Direction = 'received' | 'issued';
// ===== 매체 =====
export type Medium = 'electronic' | 'paper';
// ===== 이력 레코드 (V8: 처리구분/상대처 추가) =====
export interface HistoryRecord {
id: string;
date: string;
type: string; // 처리구분 (HISTORY_TYPE_OPTIONS)
amount: number;
counterparty: string; // 상대처
note: string;
}
// ===== V8 폼 데이터 (전체 ~45개 필드) =====
export interface BillFormData {
// === 공통 ===
billNumber: string;
instrumentType: InstrumentType;
direction: Direction;
medium: Medium;
amount: number;
issueDate: string;
maturityDate: string;
note: string;
// === 전자어음 (조건: medium=electronic) ===
electronicBillNo: string;
registrationOrg: string;
// === 환어음 (조건: instrumentType=exchange) ===
drawee: string;
acceptanceStatus: string;
acceptanceDate: string;
// === 받을어음 전용 ===
vendor: string;
billCategory: string;
issuerBank: string;
endorsement: string;
endorsementOrder: string;
storagePlace: string;
receivedStatus: string;
isDiscounted: boolean;
discountDate: string;
discountBank: string;
discountRate: number;
discountAmount: number;
// 배서양도
endorsementDate: string;
endorsee: string;
endorsementReason: string;
// 추심
collectionBank: string;
collectionRequestDate: string;
collectionFee: number;
collectionCompleteDate: string;
collectionResult: string;
collectionDepositDate: string;
collectionDepositAmount: number;
// === 지급어음 전용 ===
payee: string;
settlementBank: string;
paymentMethod: string;
issuedStatus: string;
actualPaymentDate: string;
// === 공통 ===
paymentPlace: string;
paymentPlaceDetail: string;
// === 개서 ===
renewalDate: string;
renewalNewBillNo: string;
renewalReason: string;
// === 소구/환매 ===
recourseDate: string;
recourseAmount: number;
recourseTarget: string;
recourseReason: string;
buybackDate: string;
buybackAmount: number;
buybackBank: string;
// === 환어음 인수거절 ===
acceptanceRefusalDate: string;
acceptanceRefusalReason: string;
// === 공통 조건부 ===
isSplit: boolean;
splitCount: number;
splitAmount: number;
dishonoredDate: string;
dishonoredReason: string;
// 부도 법적 프로세스
hasProtest: boolean;
protestDate: string;
recourseNoticeDate: string;
recourseNoticeDeadline: string;
// === 이력 관리 ===
installments: HistoryRecord[];
// === 입출금 계좌 ===
bankAccountInfo: string;
}
// ===== 초기 폼 데이터 =====
export const INITIAL_BILL_FORM_DATA: BillFormData = {
billNumber: '', instrumentType: 'promissory', direction: 'received',
medium: 'paper', amount: 0, issueDate: '', maturityDate: '', note: '',
electronicBillNo: '', registrationOrg: '',
drawee: '', acceptanceStatus: '', acceptanceDate: '',
vendor: '', billCategory: 'commercial', issuerBank: '', endorsement: 'endorsable', endorsementOrder: '1',
storagePlace: '', receivedStatus: 'stored', isDiscounted: false,
discountDate: '', discountBank: '', discountRate: 0, discountAmount: 0,
endorsementDate: '', endorsee: '', endorsementReason: '',
collectionBank: '', collectionRequestDate: '', collectionFee: 0,
collectionCompleteDate: '', collectionResult: '', collectionDepositDate: '', collectionDepositAmount: 0,
payee: '', settlementBank: '', paymentMethod: 'autoTransfer',
issuedStatus: 'stored', actualPaymentDate: '',
paymentPlace: '', paymentPlaceDetail: '',
renewalDate: '', renewalNewBillNo: '', renewalReason: '',
recourseDate: '', recourseAmount: 0, recourseTarget: '', recourseReason: '',
buybackDate: '', buybackAmount: 0, buybackBank: '',
acceptanceRefusalDate: '', acceptanceRefusalReason: '',
isSplit: false, splitCount: 0, splitAmount: 0,
dishonoredDate: '', dishonoredReason: '',
hasProtest: false, protestDate: '', recourseNoticeDate: '', recourseNoticeDeadline: '',
installments: [], bankAccountInfo: '',
};
// ===== BillApiData → BillFormData 직접 변환 (V8 전체 필드 매핑) =====
export function apiDataToFormData(apiData: BillApiData): BillFormData {
const pf = (v: string | null | undefined) => v ? parseFloat(v) : 0;
return {
...INITIAL_BILL_FORM_DATA,
billNumber: apiData.bill_number,
instrumentType: (apiData.instrument_type as InstrumentType) || 'promissory',
direction: apiData.bill_type as Direction,
medium: (apiData.medium as Medium) || (apiData.is_electronic ? 'electronic' : 'paper'),
amount: parseFloat(apiData.amount),
issueDate: apiData.issue_date,
maturityDate: apiData.maturity_date || '',
note: apiData.note || '',
// 전자어음
electronicBillNo: apiData.electronic_bill_no || '',
registrationOrg: apiData.registration_org || '',
// 환어음
drawee: apiData.drawee || '',
acceptanceStatus: apiData.acceptance_status || '',
acceptanceDate: apiData.acceptance_date || '',
acceptanceRefusalDate: apiData.acceptance_refusal_date || '',
acceptanceRefusalReason: apiData.acceptance_refusal_reason || '',
// 거래처
vendor: apiData.bill_type === 'received' && apiData.client_id ? String(apiData.client_id) : '',
payee: apiData.bill_type === 'issued' && apiData.client_id ? String(apiData.client_id) : '',
// 받을어음 전용
billCategory: apiData.bill_category || 'commercial',
issuerBank: apiData.issuer_bank || '',
endorsement: apiData.endorsement || 'endorsable',
endorsementOrder: apiData.endorsement_order || '1',
storagePlace: apiData.storage_place || '',
receivedStatus: apiData.bill_type === 'received' ? apiData.status : 'stored',
isDiscounted: apiData.is_discounted ?? false,
discountDate: apiData.discount_date || '',
discountBank: apiData.discount_bank || '',
discountRate: pf(apiData.discount_rate),
discountAmount: pf(apiData.discount_amount),
endorsementDate: apiData.endorsement_date || '',
endorsee: apiData.endorsee || '',
endorsementReason: apiData.endorsement_reason || '',
collectionBank: apiData.collection_bank || '',
collectionRequestDate: apiData.collection_request_date || '',
collectionFee: pf(apiData.collection_fee),
collectionCompleteDate: apiData.collection_complete_date || '',
collectionResult: apiData.collection_result || '',
collectionDepositDate: apiData.collection_deposit_date || '',
collectionDepositAmount: pf(apiData.collection_deposit_amount),
// 지급어음 전용
settlementBank: apiData.settlement_bank || '',
paymentMethod: apiData.payment_method || 'autoTransfer',
issuedStatus: apiData.bill_type === 'issued' ? apiData.status : 'stored',
actualPaymentDate: apiData.actual_payment_date || '',
// 공통
paymentPlace: apiData.payment_place || '',
paymentPlaceDetail: apiData.payment_place_detail || '',
// 개서
renewalDate: apiData.renewal_date || '',
renewalNewBillNo: apiData.renewal_new_bill_no || '',
renewalReason: apiData.renewal_reason || '',
// 소구/환매
recourseDate: apiData.recourse_date || '',
recourseAmount: pf(apiData.recourse_amount),
recourseTarget: apiData.recourse_target || '',
recourseReason: apiData.recourse_reason || '',
buybackDate: apiData.buyback_date || '',
buybackAmount: pf(apiData.buyback_amount),
buybackBank: apiData.buyback_bank || '',
// 부도
isSplit: apiData.is_split ?? false,
splitCount: 0,
splitAmount: 0,
dishonoredDate: apiData.dishonored_date || '',
dishonoredReason: apiData.dishonored_reason || '',
hasProtest: apiData.has_protest ?? false,
protestDate: apiData.protest_date || '',
recourseNoticeDate: apiData.recourse_notice_date || '',
recourseNoticeDeadline: apiData.recourse_notice_deadline || '',
// 이력
installments: (apiData.installments || []).map(inst => ({
id: String(inst.id),
date: inst.installment_date,
type: inst.type || 'other',
amount: parseFloat(inst.amount),
counterparty: inst.counterparty || '',
note: inst.note || '',
})),
bankAccountInfo: apiData.bank_account_id ? String(apiData.bank_account_id) : '',
};
}
// ===== BillRecord → BillFormData 변환 (하위호환 유지) =====
export function billRecordToFormData(record: BillRecord): BillFormData {
return {
...INITIAL_BILL_FORM_DATA,
billNumber: record.billNumber,
direction: record.billType as Direction,
amount: record.amount,
issueDate: record.issueDate,
maturityDate: record.maturityDate,
note: record.note,
receivedStatus: record.billType === 'received' ? record.status : 'stored',
issuedStatus: record.billType === 'issued' ? record.status : 'stored',
vendor: record.billType === 'received' ? record.vendorId : '',
payee: record.billType === 'issued' ? record.vendorId : '',
installments: record.installments.map(inst => ({
id: inst.id,
date: inst.date,
type: 'other',
amount: inst.amount,
counterparty: '',
note: inst.note,
})),
};
}

View File

@@ -55,6 +55,29 @@ import { JournalEntryModal } from './JournalEntryModal';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber } from '@/lib/utils/amount';
import { filterByEnum } from '@/lib/utils/search';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<CardTransaction>[] = [
{ header: '사용일시', key: 'usedAt', width: 18 },
{ header: '카드사', key: 'cardCompany', width: 10 },
{ header: '카드번호', key: 'card', width: 12 },
{ header: '카드명', key: 'cardName', width: 12 },
{ header: '공제', key: 'deductionType', width: 10,
transform: (v) => v === 'deductible' ? '공제' : '불공제' },
{ header: '사업자번호', key: 'businessNumber', width: 15 },
{ header: '가맹점명', key: 'merchantName', width: 15 },
{ header: '증빙/판매자상호', key: 'vendorName', width: 18 },
{ header: '내역', key: 'description', width: 15 },
{ header: '합계금액', key: 'totalAmount', width: 12 },
{ header: '공급가액', key: 'supplyAmount', width: 12 },
{ header: '세액', key: 'taxAmount', width: 10 },
{ header: '계정과목', key: 'accountSubject', width: 12,
transform: (v) => {
const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v);
return found?.label || String(v || '');
}},
];
// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) =====
const tableColumns = [
@@ -269,9 +292,45 @@ export function CardTransactionInquiry() {
setShowJournalEntry(true);
}, []);
const handleExcelDownload = useCallback(() => {
toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.');
}, []);
const handleExcelDownload = useCallback(async () => {
try {
toast.info('엑셀 파일 생성 중...');
const allData: CardTransaction[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getCardTransactionList({
startDate,
endDate,
search: searchQuery || undefined,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel<CardTransaction & Record<string, unknown>>({
data: allData as (CardTransaction & Record<string, unknown>)[],
columns: excelColumns as ExcelColumn<CardTransaction & Record<string, unknown>>[],
filename: '카드사용내역',
sheetName: '카드사용내역',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, searchQuery]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<CardTransaction> = useMemo(

View File

@@ -1,9 +1,9 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { format, parseISO } from 'date-fns';
import { format, parseISO, subMonths, startOfMonth, endOfMonth } from 'date-fns';
import { ko } from 'date-fns/locale';
import { Download, FileText, Loader2, RefreshCw, Calendar } from 'lucide-react';
import { Download, FileText, Loader2, Printer, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
@@ -15,18 +15,28 @@ import {
TableRow,
TableFooter,
} from '@/components/ui/table';
import { DatePicker } from '@/components/ui/date-picker';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import { Input } from '@/components/ui/input';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import { Badge } from '@/components/ui/badge';
import { printElement } from '@/lib/print-utils';
import type { NoteReceivableItem, DailyAccountItem } from './types';
import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types';
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
// ===== 빠른 월 선택 버튼 정의 =====
const QUICK_MONTH_BUTTONS = [
{ label: '이번달', months: 0 },
{ label: '지난달', months: 1 },
{ label: 'D-2월', months: 2 },
{ label: 'D-3월', months: 3 },
{ label: 'D-4월', months: 4 },
{ label: 'D-5월', months: 5 },
] as const;
// ===== Props 인터페이스 =====
interface DailyReportProps {
initialNoteReceivables?: NoteReceivableItem[];
@@ -36,7 +46,9 @@ interface DailyReportProps {
export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts = [] }: DailyReportProps) {
const { canExport } = usePermission();
// ===== 상태 관리 =====
const [selectedDate, setSelectedDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
const [searchTerm, setSearchTerm] = useState('');
const [noteReceivables, setNoteReceivables] = useState<NoteReceivableItem[]>(initialNoteReceivables);
const [dailyAccounts, setDailyAccounts] = useState<DailyAccountItem[]>(initialDailyAccounts);
const [summary, setSummary] = useState<{
@@ -53,9 +65,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
setIsLoading(true);
try {
const [noteResult, accountResult, summaryResult] = await Promise.all([
getNoteReceivables({ date: selectedDate }),
getDailyAccounts({ date: selectedDate }),
getDailyReportSummary({ date: selectedDate }),
getNoteReceivables({ date: startDate }),
getDailyAccounts({ date: startDate }),
getDailyReportSummary({ date: startDate }),
]);
if (noteResult.success) {
@@ -81,20 +93,20 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
} finally {
setIsLoading(false);
}
}, [selectedDate]);
}, [startDate]);
// ===== 초기 로드 및 날짜 변경시 재로드 =====
const isInitialMount = useRef(true);
const prevDateRef = useRef(selectedDate);
const prevDateRef = useRef(startDate);
useEffect(() => {
// 초기 마운트 또는 날짜가 실제로 변경된 경우에만 로드
if (isInitialMount.current || prevDateRef.current !== selectedDate) {
if (isInitialMount.current || prevDateRef.current !== startDate) {
isInitialMount.current = false;
prevDateRef.current = selectedDate;
prevDateRef.current = startDate;
loadData();
}
}, [selectedDate, loadData]);
}, [startDate, loadData]);
// ===== 어음 합계 (API 요약 사용) =====
const noteReceivableTotal = useMemo(() => {
@@ -144,9 +156,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
}, [accountTotals]);
// ===== 선택된 날짜 정보 =====
const selectedDateInfo = useMemo(() => {
const startDateInfo = useMemo(() => {
try {
const date = parseISO(selectedDate);
const date = parseISO(startDate);
return {
formatted: format(date, 'yyyy년 M월 d일', { locale: ko }),
dayOfWeek: format(date, 'EEEE', { locale: ko }),
@@ -154,12 +166,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
} catch {
return { formatted: '', dayOfWeek: '' };
}
}, [selectedDate]);
}, [startDate]);
// ===== 엑셀 다운로드 (프록시 API 직접 호출) =====
const handleExcelDownload = useCallback(async () => {
try {
const url = `/api/proxy/daily-report/export?date=${selectedDate}`;
const url = `/api/proxy/daily-report/export?date=${startDate}`;
const response = await fetch(url);
if (!response.ok) {
@@ -169,7 +181,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
const blob = await response.blob();
const contentDisposition = response.headers.get('Content-Disposition');
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${selectedDate}.xlsx`;
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${startDate}.xlsx`;
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -183,7 +195,55 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
} catch {
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
}
}, [selectedDate]);
}, [startDate]);
// ===== 빠른 월 선택 =====
const handleQuickMonth = useCallback((monthsAgo: number) => {
const target = monthsAgo === 0 ? new Date() : subMonths(new Date(), monthsAgo);
setStartDate(format(startOfMonth(target), 'yyyy-MM-dd'));
setEndDate(format(endOfMonth(target), 'yyyy-MM-dd'));
}, []);
// ===== 인쇄 =====
const printAreaRef = useRef<HTMLDivElement>(null);
const handlePrint = useCallback(() => {
if (printAreaRef.current) {
printElement(printAreaRef.current, {
title: `일일일보_${startDate}`,
styles: `
.print-container { font-size: 11px; }
table { width: 100%; margin-bottom: 12px; }
h3 { margin-bottom: 8px; }
`,
});
}
}, [startDate]);
// ===== USD 금액 포맷 =====
const formatUsd = useCallback((value: number) => `$ ${formatAmount(value)}`, []);
// ===== 검색 필터링 =====
const filteredNoteReceivables = useMemo(() => {
if (!searchTerm) return noteReceivables;
const term = searchTerm.toLowerCase();
return noteReceivables.filter(item =>
item.content.toLowerCase().includes(term)
);
}, [noteReceivables, searchTerm]);
const filteredDailyAccounts = useMemo(() => {
if (!searchTerm) return dailyAccounts;
const term = searchTerm.toLowerCase();
return dailyAccounts.filter(item =>
item.category.toLowerCase().includes(term)
);
}, [dailyAccounts, searchTerm]);
// ===== USD 데이터 존재 여부 =====
const hasUsdAccounts = useMemo(() =>
filteredDailyAccounts.some(item => item.currency === 'USD'),
[filteredDailyAccounts]
);
return (
<PageLayout>
@@ -194,62 +254,81 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
icon={FileText}
/>
{/* 헤더 액션 (날짜 선택, 버튼 등) */}
{/* 헤더 액션 (날짜 선택, 빠른 월 선택, 검색, 인쇄) */}
<Card>
<CardContent className="p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 min-w-0">
<Calendar className="h-4 w-4 text-gray-500 shrink-0" />
<span className="text-sm font-medium text-gray-700 shrink-0"> </span>
<DatePicker
value={selectedDate}
onChange={setSelectedDate}
className="w-auto min-w-[140px]"
size="sm"
align="start"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={isLoading}
className="h-8 px-2 text-xs"
>
{isLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-1 h-3.5 w-3.5" />
)}
</Button>
{canExport && (
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-8 px-2 text-xs">
<Download className="mr-1 h-3.5 w-3.5" />
<CardContent className="p-3 md:p-4">
<div className="flex flex-col gap-2 md:gap-3">
{/* DateRange */}
<DateRangePicker
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
size="sm"
className="w-full md:w-auto md:min-w-[280px]"
displayFormat="yyyy-MM-dd"
/>
{/* 빠른 월 선택 버튼 - 모바일: 가로 스크롤 */}
<div className="flex items-center gap-1.5 md:gap-2 overflow-x-auto pb-1 -mb-1">
{QUICK_MONTH_BUTTONS.map((btn) => (
<Button
key={btn.label}
variant="outline"
size="sm"
className="h-7 md:h-8 px-2 md:px-2.5 text-xs shrink-0"
onClick={() => handleQuickMonth(btn.months)}
>
{btn.label}
</Button>
)}
))}
</div>
{/* 검색 + 인쇄/엑셀 - 모바일: 세로 배치 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
<div className="relative flex-1 sm:max-w-[300px]">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="검색..."
className="pl-8 h-8 text-sm"
/>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handlePrint} className="h-7 md:h-8 px-2 md:px-3 text-xs">
<Printer className="mr-1 h-3.5 w-3.5" />
</Button>
{canExport && (
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-7 md:h-8 px-2 md:px-3 text-xs">
<Download className="mr-1 h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
</CardContent>
</Card>
{/* 어음 및 외상매출채권현황 */}
{/* 인쇄 영역 */}
<div ref={printAreaRef} className="print-area space-y-4 md:space-y-6">
{/* 일자별 입출금 합계 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold"> </h3>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
<div className="flex items-center justify-between mb-3 md:mb-4">
<h3 className="text-base md:text-lg font-semibold">
: {startDateInfo.formatted} {startDateInfo.dayOfWeek}
</h3>
</div>
<div className="rounded-md border overflow-x-auto">
<div className="min-w-[550px]">
<Table>
<TableHeader>
<div className="rounded-md border overflow-x-auto max-h-[40vh] md:max-h-[50vh] overflow-y-auto">
<div className="min-w-[420px] md:min-w-[650px]">
<table className="w-full caption-bottom text-sm">
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
<TableRow>
<TableHead className="font-semibold max-w-[200px]"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"> </TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -258,129 +337,343 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
<TableCell colSpan={4} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="text-gray-500"> ...</span>
<span className="text-gray-500 text-sm"> ...</span>
</div>
</TableCell>
</TableRow>
) : noteReceivables.length === 0 ? (
) : filteredDailyAccounts.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
.
</TableCell>
</TableRow>
) : (
noteReceivables.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[200px] truncate">{item.content}</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.currentBalance)}</TableCell>
<TableCell className="text-center whitespace-nowrap">{item.issueDate}</TableCell>
<TableCell className="text-center whitespace-nowrap">{item.dueDate}</TableCell>
</TableRow>
))
)}
</TableBody>
{noteReceivables.length > 0 && (
<TableFooter>
<TableRow className="bg-muted/50 font-medium">
<TableCell className="font-bold"></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(noteReceivableTotal)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableFooter>
)}
</Table>
</div>
</div>
</CardContent>
</Card>
{/* 일자별 상세 */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">
: {selectedDateInfo.formatted} {selectedDateInfo.dayOfWeek}
</h3>
</div>
<div className="rounded-md border overflow-x-auto">
<div className="min-w-[650px]">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold max-w-[180px]"></TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"> </TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="text-gray-500"> ...</span>
</div>
</TableCell>
</TableRow>
) : dailyAccounts.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
<>
{/* KRW 계좌들 */}
{dailyAccounts
{filteredDailyAccounts
.filter(item => item.currency === 'KRW')
.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[180px] truncate">{item.category}</TableCell>
<TableCell className="text-center whitespace-nowrap">
<Badge className={MATCH_STATUS_COLORS[item.matchStatus]}>
{MATCH_STATUS_LABELS[item.matchStatus]}
</Badge>
</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.carryover)}</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.balance)}</TableCell>
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.balance)}</TableCell>
</TableRow>
))}
{/* KRW 소계 */}
{hasUsdAccounts && (
<TableRow className="bg-muted/30 font-medium">
<TableCell className="text-xs md:text-sm font-semibold">(KRW) </TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.balance)}</TableCell>
</TableRow>
)}
{/* USD 계좌들 */}
{hasUsdAccounts && filteredDailyAccounts
.filter(item => item.currency === 'USD')
.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.balance)}</TableCell>
</TableRow>
))}
{/* USD 소계 */}
{hasUsdAccounts && (
<TableRow className="bg-muted/30 font-medium">
<TableCell className="text-xs md:text-sm font-semibold">(USD) </TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.balance)}</TableCell>
</TableRow>
)}
</>
)}
</TableBody>
{dailyAccounts.length > 0 && (
{filteredDailyAccounts.length > 0 && (
<TableFooter>
{/* 외화원 (USD) 합계 */}
<TableRow className="bg-blue-50/50">
<TableCell className="font-semibold whitespace-nowrap"> (USD) </TableCell>
<TableCell></TableCell>
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.carryover)}</TableCell>
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.income)}</TableCell>
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.expense)}</TableCell>
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.balance)}</TableCell>
</TableRow>
{/* 현금성 자산 합계 */}
{/* 합계 */}
<TableRow className="bg-muted/50 font-medium">
<TableCell className="font-bold whitespace-nowrap"> </TableCell>
<TableCell></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.carryover)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.income)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.expense)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.balance)}</TableCell>
<TableCell className="font-bold whitespace-nowrap text-xs md:text-sm"></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.balance)}</TableCell>
</TableRow>
</TableFooter>
)}
</Table>
</table>
</div>
</div>
</CardContent>
</Card>
{/* 예금 입출금 내역 */}
<Card>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
<div className="flex items-center justify-center mb-3 md:mb-4 py-1.5 md:py-2 bg-gray-100 rounded-md">
<h3 className="text-base md:text-lg font-semibold"> </h3>
</div>
{/* KRW 입출금 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
{/* KRW 입금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-blue-50 rounded-t-md border border-b-0">
<span className="font-semibold text-blue-700 text-sm md:text-base"> (KRW)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6">
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
</TableCell>
</TableRow>
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
.
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'KRW' && item.income > 0)
.map((item) => (
<TableRow key={`deposit-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-blue-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
{/* KRW 출금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-red-50 rounded-t-md border border-b-0">
<span className="font-semibold text-red-700 text-sm md:text-base"> (KRW)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6">
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
</TableCell>
</TableRow>
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
.
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'KRW' && item.expense > 0)
.map((item) => (
<TableRow key={`withdrawal-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-red-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
</div>
{/* USD 입출금 — USD 데이터가 있을 때만 표시 */}
{hasUsdAccounts && (
<>
<div className="flex items-center justify-center mt-4 mb-3 py-1.5 md:py-2 bg-emerald-50 rounded-md">
<h3 className="text-base md:text-lg font-semibold text-emerald-800">(USD) </h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
{/* USD 입금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-emerald-50 rounded-t-md border border-b-0">
<span className="font-semibold text-emerald-700 text-sm md:text-base"> (USD)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.income > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
USD .
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'USD' && item.income > 0)
.map((item) => (
<TableRow key={`usd-deposit-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-emerald-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.income)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
{/* USD 출금 */}
<div>
<div className="text-center py-1 md:py-1.5 bg-orange-50 rounded-t-md border border-b-0">
<span className="font-semibold text-orange-700 text-sm md:text-base"> (USD)</span>
</div>
<div className="rounded-b-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm">/</TableHead>
<TableHead className="font-semibold text-right text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.expense > 0).length === 0 ? (
<TableRow>
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
USD .
</TableCell>
</TableRow>
) : (
filteredDailyAccounts
.filter(item => item.currency === 'USD' && item.expense > 0)
.map((item) => (
<TableRow key={`usd-withdrawal-${item.id}`} className="hover:bg-muted/50">
<TableCell>
<div className="text-xs md:text-sm">{item.category}</div>
</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
</TableRow>
))
)}
</TableBody>
<TableFooter>
<TableRow className="bg-orange-50/50">
<TableCell className="font-bold text-xs md:text-sm"> </TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.expense)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
</div>
</div>
</>
)}
</CardContent>
</Card>
{/* 어음 및 외상매출채권현황 */}
<Card>
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
<div className="flex items-center justify-between mb-3 md:mb-4">
<h3 className="text-base md:text-lg font-semibold"> </h3>
</div>
<div className="rounded-md border overflow-x-auto max-h-[25vh] md:max-h-[30vh] overflow-y-auto">
<div className="min-w-[480px] md:min-w-[550px]">
<table className="w-full caption-bottom text-sm">
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
<TableRow>
<TableHead className="font-semibold text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm"> </TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm"></TableHead>
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8">
<div className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
<span className="text-gray-500 text-sm"> ...</span>
</div>
</TableCell>
</TableRow>
) : filteredNoteReceivables.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
.
</TableCell>
</TableRow>
) : (
filteredNoteReceivables.map((item) => (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="max-w-[160px] md:max-w-[200px] truncate text-xs md:text-sm">{item.content}</TableCell>
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.currentBalance)}</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.issueDate}</TableCell>
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.dueDate}</TableCell>
</TableRow>
))
)}
</TableBody>
{filteredNoteReceivables.length > 0 && (
<TableFooter className="sticky bottom-0 z-10 bg-background">
<TableRow className="bg-muted/50 font-medium">
<TableCell className="font-bold text-xs md:text-sm"></TableCell>
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(noteReceivableTotal)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
</TableFooter>
)}
</table>
</div>
</div>
</CardContent>
</Card>
</div>
</PageLayout>
);
}
}

View File

@@ -1,144 +1,106 @@
/**
* 상품권 관리 서버 액션 (Mock)
* 상품권 관리 서버 액션
*
* API Endpoints (예정):
* - GET /api/v1/gift-certificates - 목록 조회
* - GET /api/v1/gift-certificates/{id} - 상세 조회
* - POST /api/v1/gift-certificates - 등록
* - PUT /api/v1/gift-certificates/{id} - 수정
* - DELETE /api/v1/gift-certificates/{id} - 삭제
* - GET /api/v1/gift-certificates/summary - 요약 통계
* API Endpoints (Loan API 재사용, category='gift_certificate'):
* - GET /api/v1/loans?category=gift_certificate - 목록 조회
* - GET /api/v1/loans/{id} - 상세 조회
* - POST /api/v1/loans - 등록
* - PUT /api/v1/loans/{id} - 수정
* - DELETE /api/v1/loans/{id} - 삭제
* - GET /api/v1/loans/summary?category=gift_certificate - 요약 통계
*/
'use server';
import type { ActionResult } from '@/lib/api/execute-server-action';
// import { executeServerAction } from '@/lib/api/execute-server-action';
// import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
// import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { executePaginatedAction, type PaginatedActionResult } from '@/lib/api/execute-paginated-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type {
GiftCertificateRecord,
GiftCertificateFormData,
LoanApiData,
} from './types';
import {
transformApiToRecord,
transformApiToFormData,
transformFormToApi,
} from './types';
// ===== 상품권 목록 조회 (Mock) =====
export async function getGiftCertificates(_params?: {
// ===== 상품권 목록 조회 =====
export async function getGiftCertificates(params?: {
page?: number;
perPage?: number;
startDate?: string;
endDate?: string;
status?: string;
}): Promise<ActionResult<GiftCertificateRecord[]>> {
// TODO: 실제 API 연동 시 교체
// return executePaginatedAction<GiftCertificateApiData, GiftCertificateRecord>({
// url: buildApiUrl('/api/v1/gift-certificates', { ... }),
// transform: transformApiToFrontend,
// errorMessage: '상품권 목록 조회에 실패했습니다.',
// });
return { success: true, data: [] };
search?: string;
}): Promise<PaginatedActionResult<GiftCertificateRecord>> {
return executePaginatedAction<LoanApiData, GiftCertificateRecord>({
url: buildApiUrl('/api/v1/loans', {
category: 'gift_certificate',
page: params?.page,
per_page: params?.perPage,
start_date: params?.startDate,
end_date: params?.endDate,
status: params?.status && params.status !== 'all' ? params.status : undefined,
search: params?.search,
}),
transform: transformApiToRecord,
errorMessage: '상품권 목록 조회에 실패했습니다.',
});
}
// ===== 상품권 상세 조회 (Mock) =====
// ===== 상품권 상세 조회 =====
export async function getGiftCertificateById(
_id: string
id: string
): Promise<ActionResult<GiftCertificateFormData>> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
// transform: transformDetailApiToFrontend,
// errorMessage: '상품권 조회에 실패했습니다.',
// });
return {
success: true,
data: {
serialNumber: 'GC-2026-001',
name: '신세계 상품권',
faceValue: 500000,
vendorId: '',
vendorName: '신세계백화점',
purchaseDate: '2026-02-10',
purchasePurpose: 'entertainment',
entertainmentExpense: 'applicable',
status: 'used',
usedDate: '2026-02-20',
recipientName: '홍길동',
recipientOrganization: '(주)테크솔루션',
usageDescription: '거래처 접대용',
memo: '2월 접대비 처리 완료',
},
};
return executeServerAction({
url: buildApiUrl(`/api/v1/loans/${id}`),
transform: (data: LoanApiData) => transformApiToFormData(data),
errorMessage: '상품권 조회에 실패했습니다.',
});
}
// ===== 상품권 등록 (Mock) =====
// ===== 상품권 등록 =====
export async function createGiftCertificate(
_data: GiftCertificateFormData
data: GiftCertificateFormData
): Promise<ActionResult<GiftCertificateRecord>> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl('/api/v1/gift-certificates'),
// method: 'POST',
// body: transformFrontendToApi(data),
// transform: transformApiToFrontend,
// errorMessage: '상품권 등록에 실패했습니다.',
// });
return {
success: true,
data: {
id: crypto.randomUUID(),
serialNumber: _data.serialNumber || `GC-${Date.now()}`,
name: _data.name,
faceValue: _data.faceValue,
purchaseDate: _data.purchaseDate,
usedDate: _data.usedDate || null,
status: _data.status,
entertainmentExpense: _data.entertainmentExpense,
},
};
return executeServerAction({
url: buildApiUrl('/api/v1/loans'),
method: 'POST',
body: transformFormToApi(data),
transform: (d: LoanApiData) => transformApiToRecord(d),
errorMessage: '상품권 등록에 실패했습니다.',
});
}
// ===== 상품권 수정 (Mock) =====
// ===== 상품권 수정 =====
export async function updateGiftCertificate(
_id: string,
_data: GiftCertificateFormData
id: string,
data: GiftCertificateFormData
): Promise<ActionResult<GiftCertificateRecord>> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
// method: 'PUT',
// body: transformFrontendToApi(data),
// transform: transformApiToFrontend,
// errorMessage: '상품권 수정에 실패했습니다.',
// });
return {
success: true,
data: {
id: _id,
serialNumber: _data.serialNumber,
name: _data.name,
faceValue: _data.faceValue,
purchaseDate: _data.purchaseDate,
usedDate: _data.usedDate || null,
status: _data.status,
entertainmentExpense: _data.entertainmentExpense,
},
};
return executeServerAction({
url: buildApiUrl(`/api/v1/loans/${id}`),
method: 'PUT',
body: transformFormToApi(data),
transform: (d: LoanApiData) => transformApiToRecord(d),
errorMessage: '상품권 수정에 실패했습니다.',
});
}
// ===== 상품권 삭제 (Mock) =====
// ===== 상품권 삭제 =====
export async function deleteGiftCertificate(
_id: string
id: string
): Promise<ActionResult> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
// method: 'DELETE',
// errorMessage: '상품권 삭제에 실패했습니다.',
// });
return { success: true };
return executeServerAction({
url: buildApiUrl(`/api/v1/loans/${id}`),
method: 'DELETE',
errorMessage: '상품권 삭제에 실패했습니다.',
});
}
// ===== 상품권 요약 통계 (Mock) =====
export async function getGiftCertificateSummary(_params?: {
// ===== 상품권 요약 통계 =====
export async function getGiftCertificateSummary(params?: {
startDate?: string;
endDate?: string;
}): Promise<ActionResult<{
@@ -151,23 +113,29 @@ export async function getGiftCertificateSummary(_params?: {
entertainmentCount: number;
entertainmentAmount: number;
}>> {
// TODO: 실제 API 연동 시 교체
// return executeServerAction({
// url: buildApiUrl('/api/v1/gift-certificates/summary', { ... }),
// transform: transformSummary,
// errorMessage: '상품권 요약 조회에 실패했습니다.',
// });
return {
success: true,
data: {
totalCount: 0,
totalAmount: 0,
holdingCount: 0,
holdingAmount: 0,
usedCount: 0,
usedAmount: 0,
return executeServerAction({
url: buildApiUrl('/api/v1/loans/summary', {
category: 'gift_certificate',
start_date: params?.startDate,
end_date: params?.endDate,
}),
transform: (data: {
total_count: number;
total_amount: number;
holding_count?: number;
holding_amount?: number;
used_count?: number;
used_amount?: number;
}) => ({
totalCount: data.total_count ?? 0,
totalAmount: data.total_amount ?? 0,
holdingCount: data.holding_count ?? 0,
holdingAmount: data.holding_amount ?? 0,
usedCount: data.used_count ?? 0,
usedAmount: data.used_amount ?? 0,
entertainmentCount: 0,
entertainmentAmount: 0,
},
};
}),
errorMessage: '상품권 요약 조회에 실패했습니다.',
});
}

View File

@@ -104,3 +104,94 @@ export function createEmptyFormData(): GiftCertificateFormData {
// ===== 액면가 50만원 기준 =====
export const FACE_VALUE_THRESHOLD = 500000;
// ===== Loan API 응답 타입 =====
export interface LoanApiData {
id: number;
tenant_id: number;
user_id: number | null;
loan_date: string;
amount: string;
purpose: string | null;
settlement_date: string | null;
settlement_amount: string | null;
status: string;
category: string | null;
metadata: {
serial_number?: string;
cert_name?: string;
vendor_id?: string;
vendor_name?: string;
purchase_purpose?: string;
entertainment_expense?: string;
recipient_name?: string;
recipient_organization?: string;
usage_description?: string;
memo?: string;
} | null;
withdrawal_id: number | null;
created_by: number | null;
updated_by: number | null;
user?: { id: number; name: string; email: string } | null;
creator?: { id: number; name: string } | null;
}
// ===== API → 프론트 변환 (목록용) =====
export function transformApiToRecord(api: LoanApiData): GiftCertificateRecord {
const meta = api.metadata ?? {};
return {
id: String(api.id),
serialNumber: meta.serial_number ?? '',
name: meta.cert_name ?? '',
faceValue: parseFloat(api.amount) || 0,
purchaseDate: api.loan_date ?? '',
usedDate: api.settlement_date ?? null,
status: (api.status as GiftCertificateStatus) ?? 'holding',
entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable',
};
}
// ===== API → 프론트 변환 (상세/폼용) =====
export function transformApiToFormData(api: LoanApiData): GiftCertificateFormData {
const meta = api.metadata ?? {};
return {
serialNumber: meta.serial_number ?? '',
name: meta.cert_name ?? '',
faceValue: parseFloat(api.amount) || 0,
vendorId: meta.vendor_id ?? '',
vendorName: meta.vendor_name ?? '',
purchaseDate: api.loan_date ?? '',
purchasePurpose: (meta.purchase_purpose as PurchasePurpose) ?? 'promotion',
entertainmentExpense: (meta.entertainment_expense as EntertainmentExpense) ?? 'not_applicable',
status: (api.status as GiftCertificateStatus) ?? 'holding',
usedDate: api.settlement_date ?? '',
recipientName: meta.recipient_name ?? '',
recipientOrganization: meta.recipient_organization ?? '',
usageDescription: meta.usage_description ?? '',
memo: meta.memo ?? '',
};
}
// ===== 프론트 → API 변환 =====
export function transformFormToApi(data: GiftCertificateFormData): Record<string, unknown> {
return {
loan_date: data.purchaseDate,
amount: data.faceValue,
purpose: data.usageDescription || null,
category: 'gift_certificate',
status: data.status,
settlement_date: data.usedDate || null,
metadata: {
serial_number: data.serialNumber || null,
cert_name: data.name || null,
vendor_id: data.vendorId || null,
vendor_name: data.vendorName || null,
purchase_purpose: data.purchasePurpose || null,
entertainment_expense: data.entertainmentExpense || null,
recipient_name: data.recipientName || null,
recipient_organization: data.recipientOrganization || null,
usage_description: data.usageDescription || null,
memo: data.memo || null,
},
};
}

View File

@@ -24,8 +24,7 @@ import { purchaseConfig } from './purchaseConfig';
import { DocumentDetailModalV2 as DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type { ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types';
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
import type { PurchaseRecord, PurchaseItem, PurchaseType } from './types';
import { PURCHASE_TYPE_LABELS } from './types';
import type { PurchaseRecord, PurchaseItem } from './types';
import {
getPurchaseById,
createPurchase,
@@ -74,7 +73,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const [purchaseDate, setPurchaseDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [vendorId, setVendorId] = useState('');
const [vendorName, setVendorName] = useState('');
const [purchaseType, setPurchaseType] = useState<PurchaseType>('unset');
// purchaseType 삭제됨 (기획서 P.109)
const [items, setItems] = useState<PurchaseItem[]>([createEmptyItem()]);
const [taxInvoiceReceived, setTaxInvoiceReceived] = useState(false);
@@ -126,7 +125,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
setPurchaseDate(data.purchaseDate);
setVendorId(data.vendorId);
setVendorName(data.vendorName);
setPurchaseType(data.purchaseType);
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
setTaxInvoiceReceived(data.taxInvoiceReceived);
setSourceDocument(data.sourceDocument);
@@ -250,7 +248,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
supplyAmount: totals.supplyAmount,
vat: totals.vat,
totalAmount: totals.total,
purchaseType,
taxInvoiceReceived,
};
@@ -275,7 +272,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
} finally {
setIsSaving(false);
}
}, [purchaseDate, vendorId, totals, purchaseType, taxInvoiceReceived, isNewMode, purchaseId]);
}, [purchaseDate, vendorId, totals, taxInvoiceReceived, isNewMode, purchaseId]);
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
@@ -301,179 +298,101 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const renderFormContent = () => (
<>
<div className="space-y-6">
{/* ===== 기본 정보 섹션 ===== */}
{/* ===== 기본 정보 섹션 (품의서/지출결의서 + 예상비용) ===== */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* 품의서/지출결의서인 경우 전용 레이아웃 */}
{sourceDocument ? (
<>
{/* 문서 타입 및 열람 버튼 */}
<div className="flex flex-col sm:flex-row sm:items-center gap-3 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<div className="flex items-center gap-2">
<Badge variant="outline" className={getPresetStyle('orange')}>
{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
</Badge>
<span className="text-sm text-muted-foreground"> </span>
</div>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 품의서/지출결의서 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Input
value={sourceDocument ? `${sourceDocument.documentNo} ${sourceDocument.title}` : ''}
readOnly
disabled
placeholder="연결된 품의서 없음"
className="bg-gray-50"
/>
<Button
variant="outline"
size="sm"
className="sm:ml-auto border-orange-300 text-orange-700 hover:bg-orange-100 w-full sm:w-auto"
className="shrink-0"
onClick={handleOpenDocument}
disabled={!sourceDocument}
>
<Eye className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 품의서/지출결의서용 필드 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 품의서/지출결의서 제목 */}
<div className="space-y-2 md:col-span-2">
<Label>{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'} </Label>
<Input
value={sourceDocument.title}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 예상비용 */}
<div className="space-y-2">
<Label></Label>
<Input
value={`${formatAmount(sourceDocument.expectedCost)}`}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 매입번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={purchaseNo}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Select
value={vendorId}
onValueChange={handleVendorChange}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 매입 유형 */}
<div className="space-y-2">
<Label> </Label>
<Select
value={purchaseType}
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="매입 유형 선택" />
</SelectTrigger>
<SelectContent>
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</>
) : (
/* 일반 매입 (품의서/지출결의서 없는 경우) */
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 매입번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={purchaseNo}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 매입일 */}
<div className="space-y-2">
<Label></Label>
<DatePicker
value={purchaseDate}
onChange={setPurchaseDate}
disabled={isViewMode}
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Select
value={vendorId}
onValueChange={handleVendorChange}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 매입 유형 */}
<div className="space-y-2">
<Label> </Label>
<Select
value={purchaseType}
onValueChange={(value) => setPurchaseType(value as PurchaseType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="매입 유형 선택" />
</SelectTrigger>
<SelectContent>
{Object.entries(PURCHASE_TYPE_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* 예상비용 */}
<div className="space-y-2">
<Label></Label>
<Input
value={sourceDocument ? `${formatAmount(sourceDocument.expectedCost)}` : ''}
readOnly
disabled
placeholder="-"
className="bg-gray-50"
/>
</div>
</div>
</CardContent>
</Card>
{/* ===== 매입 정보 섹션 ===== */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* 매입번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={purchaseNo}
readOnly
disabled
className="bg-gray-50"
/>
</div>
{/* 매입일 */}
<div className="space-y-2">
<Label></Label>
<DatePicker
value={purchaseDate}
onChange={setPurchaseDate}
disabled={isViewMode}
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Select
value={vendorId}
onValueChange={handleVendorChange}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="거래처 선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>

View File

@@ -26,8 +26,7 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
// Badge, getPresetStyle removed (매입유형/연결문서 컬럼 삭제)
import { Switch } from '@/components/ui/switch';
import {
Dialog,
@@ -57,9 +56,7 @@ import { MobileCard } from '@/components/organisms/MobileCard';
import type { PurchaseRecord } from './types';
import {
SORT_OPTIONS,
PURCHASE_TYPE_LABELS,
PURCHASE_TYPE_FILTER_OPTIONS,
ISSUANCE_FILTER_OPTIONS,
TAX_INVOICE_RECEIVED_FILTER_OPTIONS,
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
} from './types';
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
@@ -71,11 +68,9 @@ const tableColumns = [
{ key: 'purchaseNo', label: '매입번호', sortable: true },
{ key: 'purchaseDate', label: '매입일', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'sourceDocument', label: '연결문서', className: 'text-center', sortable: true },
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
{ key: 'purchaseType', label: '매입유형', className: 'text-center', sortable: true },
{ key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' },
];
@@ -92,8 +87,7 @@ export function PurchaseManagement() {
// 통합 필터 상태 (filterConfig 기반)
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
vendor: 'all',
purchaseType: 'all',
issuance: 'all',
taxInvoiceReceived: 'all',
sort: 'latest',
});
@@ -142,9 +136,8 @@ export function PurchaseManagement() {
return date.getMonth() === currentMonth && date.getFullYear() === currentYear;
})
.reduce((sum, d) => sum + d.totalAmount, 0);
const unsetTypeCount = purchaseData.filter(d => d.purchaseType === 'unset').length;
const taxInvoicePendingCount = purchaseData.filter(d => !d.taxInvoiceReceived).length;
return { totalPurchaseAmount, monthlyAmount, unsetTypeCount, taxInvoicePendingCount };
return { totalPurchaseAmount, monthlyAmount, taxInvoicePendingCount };
}, [purchaseData]);
// ===== 거래처 목록 (필터용) =====
@@ -163,17 +156,10 @@ export function PurchaseManagement() {
allOptionLabel: '거래처 전체',
},
{
key: 'purchaseType',
label: '매입유형',
key: 'taxInvoiceReceived',
label: '세금계산서 수취여부',
type: 'single',
options: PURCHASE_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
allOptionLabel: '전체',
},
{
key: 'issuance',
label: '발행여부',
type: 'single',
options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
options: TAX_INVOICE_RECEIVED_FILTER_OPTIONS.filter(o => o.value !== 'all'),
allOptionLabel: '전체',
},
{
@@ -194,8 +180,7 @@ export function PurchaseManagement() {
const handleFilterReset = useCallback(() => {
setFilterValues({
vendor: 'all',
purchaseType: 'all',
issuance: 'all',
taxInvoiceReceived: 'all',
sort: 'latest',
});
}, []);
@@ -309,18 +294,16 @@ export function PurchaseManagement() {
}
const vendorVal = fv.vendor as string;
const purchaseTypeVal = fv.purchaseType as string;
const issuanceVal = fv.issuance as string;
const taxInvoiceReceivedVal = fv.taxInvoiceReceived as string;
// 거래처 필터
if (vendorVal !== 'all' && item.vendorName !== vendorVal) {
return false;
}
// 매입유형 필터
if (purchaseTypeVal !== 'all' && item.purchaseType !== purchaseTypeVal) {
// 세금계산서 수취여부 필터
if (taxInvoiceReceivedVal === 'received' && !item.taxInvoiceReceived) {
return false;
}
// 발행여부 필터
if (issuanceVal === 'taxInvoicePending' && item.taxInvoiceReceived) {
if (taxInvoiceReceivedVal === 'notReceived' && item.taxInvoiceReceived) {
return false;
}
return true;
@@ -393,9 +376,8 @@ export function PurchaseManagement() {
// Stats 카드
computeStats: (): StatCard[] => [
{ label: '총 매입', value: `${formatNumber(stats.totalPurchaseAmount)}`, icon: Receipt, iconColor: 'text-blue-500' },
{ label: '총매입', value: `${formatNumber(stats.totalPurchaseAmount)}`, icon: Receipt, iconColor: 'text-blue-500' },
{ label: '당월 매입', value: `${formatNumber(stats.monthlyAmount)}`, icon: Receipt, iconColor: 'text-green-500' },
{ label: '매입유형 미설정', value: `${stats.unsetTypeCount}`, icon: Receipt, iconColor: 'text-orange-500' },
{ label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}`, icon: Receipt, iconColor: 'text-red-500' },
],
@@ -406,13 +388,10 @@ export function PurchaseManagement() {
<TableCell className="text-center"></TableCell>
<TableCell className="font-bold"></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalSupplyAmount)}</TableCell>
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalVat)}</TableCell>
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalAmount)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
),
@@ -428,9 +407,7 @@ export function PurchaseManagement() {
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<PurchaseRecord>
) => {
const isUnsetType = item.purchaseType === 'unset';
return (
) => (
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
@@ -443,26 +420,9 @@ export function PurchaseManagement() {
<TableCell className="text-sm font-medium">{item.purchaseNo}</TableCell>
<TableCell>{item.purchaseDate}</TableCell>
<TableCell>{item.vendorName}</TableCell>
<TableCell className="text-center">
{item.sourceDocument ? (
<Badge variant="outline" className={`text-xs ${getPresetStyle('info')}`}>
{item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
</Badge>
) : (
<span className="text-gray-400 text-xs">-</span>
)}
</TableCell>
<TableCell className="text-right">{formatNumber(item.supplyAmount)}</TableCell>
<TableCell className="text-right">{formatNumber(item.vat)}</TableCell>
<TableCell className="text-right font-medium">{formatNumber(item.totalAmount)}</TableCell>
<TableCell className="text-center">
<Badge
variant="outline"
className={isUnsetType ? 'border-red-500 text-red-500 bg-red-50' : ''}
>
{PURCHASE_TYPE_LABELS[item.purchaseType]}
</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Switch
@@ -474,8 +434,7 @@ export function PurchaseManagement() {
</div>
</TableCell>
</TableRow>
);
},
),
// 모바일 카드 렌더링
renderMobileCard: (
@@ -488,14 +447,11 @@ export function PurchaseManagement() {
key={item.id}
title={item.vendorName}
subtitle={item.purchaseNo}
badge={PURCHASE_TYPE_LABELS[item.purchaseType]}
badgeVariant="outline"
isSelected={handlers.isSelected}
onToggle={handlers.onToggle}
onClick={() => handleRowClick(item)}
details={[
{ label: '매입일', value: item.purchaseDate },
{ label: '연결문서', value: item.sourceDocument ? (item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서') : '-' },
{ label: '공급가액', value: `${formatNumber(item.supplyAmount)}` },
{ label: '합계금액', value: `${formatNumber(item.totalAmount)}` },
]}

View File

@@ -81,8 +81,8 @@ export interface PurchaseRecord {
// 정렬 옵션
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
// 발행여부 필터
export type IssuanceFilter = 'all' | 'taxInvoicePending';
// 세금계산서 수취여부 필터
export type TaxInvoiceReceivedFilter = 'all' | 'received' | 'notReceived';
// ===== 상수 정의 =====
@@ -154,10 +154,11 @@ export const PURCHASE_TYPE_FILTER_OPTIONS: { value: string; label: string }[] =
{ value: 'unset', label: '미설정' },
];
// 발행여부 필터 옵션
export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [
// 세금계산서 수취여부 필터 옵션
export const TAX_INVOICE_RECEIVED_FILTER_OPTIONS: { value: TaxInvoiceReceivedFilter; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'taxInvoicePending', label: '세금계산서 미수취' },
{ value: 'received', label: '수취 확인' },
{ value: 'notReceived', label: '수취 미확인' },
];
// 계정과목명 셀렉터 옵션 (상단 일괄 변경용)

View File

@@ -32,9 +32,10 @@ import {
CATEGORY_LABELS,
SORT_OPTIONS,
} from './types';
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions';
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos } from './actions';
import { toast } from 'sonner';
import { filterByText } from '@/lib/utils/search';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
@@ -213,27 +214,45 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
});
}, []);
// ===== 엑셀 다운로드 핸들러 =====
// ===== 엑셀 다운로드 핸들러 (프론트 xlsx 생성) =====
const handleExcelDownload = useCallback(async () => {
const result = await exportReceivablesExcel({
year: selectedYear,
search: searchQuery || undefined,
});
if (result.success && result.data) {
const url = URL.createObjectURL(result.data);
const a = document.createElement('a');
a.href = url;
a.download = result.filename || '채권현황.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('엑셀 파일이 다운로드되었습니다.');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
try {
toast.info('엑셀 파일 생성 중...');
// 데이터가 이미 로드되어 있으므로 sortedData 사용
if (sortedData.length === 0) {
toast.warning('다운로드할 데이터가 없습니다.');
return;
}
// 동적 월 컬럼 포함 엑셀 컬럼 생성
const columns: ExcelColumn<Record<string, unknown>>[] = [
{ header: '거래처', key: 'vendorName', width: 20 },
{ header: '연체', key: 'isOverdue', width: 8 },
...monthLabels.map((label, idx) => ({
header: label, key: `month_${idx}`, width: 12,
})),
{ header: '합계', key: 'total', width: 14 },
{ header: '메모', key: 'memo', width: 20 },
];
// 미수금 카테고리 기준으로 플랫 데이터 생성
const exportData = sortedData.map(vendor => {
const receivable = vendor.categories.find(c => c.category === 'receivable');
const row: Record<string, unknown> = {
vendorName: vendor.vendorName,
isOverdue: vendor.isOverdue ? '연체' : '',
};
monthLabels.forEach((_, idx) => {
row[`month_${idx}`] = receivable?.amounts.values[idx] || 0;
});
row.total = receivable?.amounts.total || 0;
row.memo = vendor.memo || '';
return row;
});
await downloadExcel({ data: exportData, columns, filename: '미수금현황', sheetName: '미수금현황' });
toast.success('엑셀 다운로드 완료');
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [selectedYear, searchQuery]);
}, [sortedData, monthLabels]);
// ===== 변경된 연체 항목 확인 =====
const changedOverdueItems = useMemo(() => {

View File

@@ -32,8 +32,7 @@ import {
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTable';
import { salesConfig } from './salesConfig';
import type { SalesRecord, SalesItem, SalesType } from './types';
import { SALES_TYPE_OPTIONS } from './types';
import type { SalesRecord, SalesItem } from './types';
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
import { toast } from 'sonner';
import { getClients } from '../VendorManagement/actions';
@@ -78,7 +77,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const [salesDate, setSalesDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [vendorId, setVendorId] = useState('');
const [vendorName, setVendorName] = useState('');
const [salesType, setSalesType] = useState<SalesType>('product');
const [items, setItems] = useState<SalesItem[]>([createEmptyItem()]);
const [taxInvoiceIssued, setTaxInvoiceIssued] = useState(false);
const [transactionStatementIssued, setTransactionStatementIssued] = useState(false);
@@ -126,7 +124,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
setSalesDate(data.salesDate);
setVendorId(data.vendorId);
setVendorName(data.vendorName);
setSalesType(data.salesType);
setItems(data.items.length > 0 ? data.items : [createEmptyItem()]);
setTaxInvoiceIssued(data.taxInvoiceIssued);
setTransactionStatementIssued(data.transactionStatementIssued);
@@ -158,7 +155,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const saleData: Partial<SalesRecord> = {
salesDate,
vendorId,
salesType,
items,
totalSupplyAmount: totals.supplyAmount,
totalVat: totals.vat,
@@ -189,7 +185,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
} finally {
setIsSaving(false);
}
}, [salesDate, vendorId, salesType, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
}, [salesDate, vendorId, items, totals, taxInvoiceIssued, transactionStatementIssued, note, isNewMode, salesId]);
// ===== 삭제 (IntegratedDetailTemplate 호환) =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
@@ -268,23 +264,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
</SelectContent>
</Select>
</div>
{/* 매출 유형 */}
<div className="space-y-2">
<Label htmlFor="salesType"> </Label>
<Select value={salesType} onValueChange={(v) => setSalesType(v as SalesType)} disabled={isViewMode}>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{SALES_TYPE_OPTIONS.filter(o => o.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardContent>
</Card>
@@ -318,28 +297,42 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
<CardTitle className="text-lg"></CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Label htmlFor="taxInvoice"> </Label>
<Switch
id="taxInvoice"
checked={taxInvoiceIssued}
onCheckedChange={setTaxInvoiceIssued}
disabled={isViewMode}
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Label htmlFor="taxInvoice"> </Label>
<Switch
id="taxInvoice"
checked={taxInvoiceIssued}
onCheckedChange={setTaxInvoiceIssued}
disabled={isViewMode}
/>
</div>
<div className="flex items-center gap-2">
{taxInvoiceIssued ? (
<span className="text-sm text-green-600 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
) : (
<span className="text-sm text-gray-500 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{taxInvoiceIssued ? (
<span className="text-sm text-green-600 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
) : (
<span className="text-sm text-gray-500 flex items-center gap-1">
<FileText className="h-4 w-4" />
</span>
)}
<div className="flex justify-end">
<Button
variant="default"
className="bg-gray-900 hover:bg-gray-800 text-white"
onClick={() => {
toast.info('세금계산서 발행 기능 준비 중입니다.');
}}
>
<FileText className="h-4 w-4 mr-2" />
</Button>
</div>
</div>
</CardContent>

View File

@@ -25,7 +25,6 @@ import {
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
@@ -55,11 +54,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
import type { SalesRecord } from './types';
import {
SORT_OPTIONS,
SALES_STATUS_LABELS,
SALES_STATUS_COLORS,
SALES_TYPE_LABELS,
SALES_TYPE_FILTER_OPTIONS,
ISSUANCE_FILTER_OPTIONS,
TAX_INVOICE_FILTER_OPTIONS,
TRANSACTION_STATEMENT_FILTER_OPTIONS,
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
} from './types';
import { getSales, deleteSale, toggleSaleIssuance } from './actions';
@@ -83,7 +79,6 @@ const tableColumns = [
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
{ key: 'salesType', label: '매출유형', className: 'text-center', sortable: true },
{ key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' },
{ key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' },
];
@@ -113,8 +108,8 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
// 필터 초기값 (filterConfig 기반 - ULP가 내부 state로 관리)
const initialFilterValues: Record<string, string | string[]> = {
vendor: 'all',
salesType: 'all',
issuance: 'all',
taxInvoice: 'all',
transactionStatement: 'all',
sort: 'latest',
};
@@ -148,17 +143,17 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
allOptionLabel: '거래처 전체',
},
{
key: 'salesType',
label: '매출유형',
key: 'taxInvoice',
label: '세금계산서 발행여부',
type: 'single',
options: SALES_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
options: TAX_INVOICE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
allOptionLabel: '전체',
},
{
key: 'issuance',
label: '발행여부',
key: 'transactionStatement',
label: '거래명세서 발행여부',
type: 'single',
options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
options: TRANSACTION_STATEMENT_FILTER_OPTIONS.filter(o => o.value !== 'all'),
allOptionLabel: '전체',
},
{
@@ -322,18 +317,24 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
// 검색은 searchFilter에서 처리하므로 여기서는 필터만 처리
customFilterFn: (items, fv) => {
if (!items || items.length === 0) return items;
const issuanceVal = fv.issuance as string;
const taxInvoiceVal = fv.taxInvoice as string;
const transactionStatementVal = fv.transactionStatement as string;
let result = applyFilters(items, [
enumFilter('vendorName', fv.vendor as string),
enumFilter('salesType', fv.salesType as string),
]);
// 발행여부 필터 (특수 로직 - enumFilter로 대체 불가)
if (issuanceVal === 'taxInvoicePending') {
// 세금계산서 발행여부 필터
if (taxInvoiceVal === 'issued') {
result = result.filter(item => item.taxInvoiceIssued);
} else if (taxInvoiceVal === 'notIssued') {
result = result.filter(item => !item.taxInvoiceIssued);
}
if (issuanceVal === 'transactionStatementPending') {
// 거래명세서 발행여부 필터
if (transactionStatementVal === 'issued') {
result = result.filter(item => item.transactionStatementIssued);
} else if (transactionStatementVal === 'notIssued') {
result = result.filter(item => !item.transactionStatementIssued);
}
@@ -411,7 +412,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
<TableCell className="text-right font-bold">{formatNumber(tableTotals.totalAmount)}</TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
),
@@ -443,9 +443,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
<TableCell className="text-right">{formatNumber(item.totalSupplyAmount)}</TableCell>
<TableCell className="text-right">{formatNumber(item.totalVat)}</TableCell>
<TableCell className="text-right font-medium">{formatNumber(item.totalAmount)}</TableCell>
<TableCell className="text-center">
<Badge variant="outline">{SALES_TYPE_LABELS[item.salesType]}</Badge>
</TableCell>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Switch
@@ -480,8 +477,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
key={item.id}
title={item.vendorName}
subtitle={item.salesNo}
badge={SALES_TYPE_LABELS[item.salesType]}
badgeVariant="outline"
isSelected={handlers.isSelected}
onToggle={handlers.onToggle}
onClick={() => handleRowClick(item)}

View File

@@ -133,13 +133,22 @@ export const ACCOUNT_SUBJECT_SELECTOR_OPTIONS = [
{ value: 'other', label: '기타매출' },
];
// ===== 발행여부 필터 =====
export type IssuanceFilter = 'all' | 'taxInvoicePending' | 'transactionStatementPending';
// ===== 세금계산서 발행여부 필터 =====
export type TaxInvoiceFilter = 'all' | 'issued' | 'notIssued';
export const ISSUANCE_FILTER_OPTIONS: { value: IssuanceFilter; label: string }[] = [
export const TAX_INVOICE_FILTER_OPTIONS: { value: TaxInvoiceFilter; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'taxInvoicePending', label: '세금계산서 미발행' },
{ value: 'transactionStatementPending', label: '거래명세서 미발행' },
{ value: 'issued', label: '발행완료' },
{ value: 'notIssued', label: '미발행' },
];
// ===== 거래명세서 발행여부 필터 =====
export type TransactionStatementFilter = 'all' | 'issued' | 'notIssued';
export const TRANSACTION_STATEMENT_FILTER_OPTIONS: { value: TransactionStatementFilter; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'issued', label: '발행완료' },
{ value: 'notIssued', label: '미발행' },
];
// ===== 매출유형 필터 옵션 (스크린샷 기준) =====

View File

@@ -45,8 +45,8 @@ import { MobileCard } from '@/components/organisms/MobileCard';
import {
getTaxInvoices,
getTaxInvoiceSummary,
downloadTaxInvoiceExcel,
} from './actions';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
const ManualEntryModal = dynamic(
() => import('./ManualEntryModal').then(mod => ({ default: mod.ManualEntryModal })),
@@ -58,6 +58,10 @@ import type {
TaxInvoiceMgmtRecord,
InvoiceTab,
TaxInvoiceSummary,
TaxType,
ReceiptType,
InvoiceStatus,
InvoiceSource,
} from './types';
import {
TAB_OPTIONS,
@@ -77,6 +81,26 @@ const QUARTER_BUTTONS = [
{ value: 'Q4', label: '4분기', startMonth: 10, endMonth: 12 },
];
// ===== 엑셀 다운로드 컬럼 =====
const excelColumns: ExcelColumn<TaxInvoiceMgmtRecord & Record<string, unknown>>[] = [
{ header: '작성일자', key: 'writeDate', width: 12 },
{ header: '발급일자', key: 'issueDate', width: 12 },
{ header: '거래처', key: 'vendorName', width: 20 },
{ header: '사업자번호', key: 'vendorBusinessNumber', width: 15 },
{ header: '과세형태', key: 'taxType', width: 10,
transform: (v) => TAX_TYPE_LABELS[v as TaxType] || String(v || '') },
{ header: '품목', key: 'itemName', width: 15 },
{ header: '공급가액', key: 'supplyAmount', width: 14 },
{ header: '세액', key: 'taxAmount', width: 14 },
{ header: '합계', key: 'totalAmount', width: 14 },
{ header: '영수청구', key: 'receiptType', width: 10,
transform: (v) => RECEIPT_TYPE_LABELS[v as ReceiptType] || String(v || '') },
{ header: '상태', key: 'status', width: 10,
transform: (v) => INVOICE_STATUS_MAP[v as InvoiceStatus]?.label || String(v || '') },
{ header: '발급형태', key: 'source', width: 10,
transform: (v) => INVOICE_SOURCE_LABELS[v as InvoiceSource] || String(v || '') },
];
// ===== 테이블 컬럼 =====
const tableColumns = [
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true },
@@ -224,19 +248,46 @@ export function TaxInvoiceManagement() {
loadData();
}, [loadData]);
// ===== 엑셀 다운로드 =====
// ===== 엑셀 다운로드 (프론트 xlsx 생성) =====
const handleExcelDownload = useCallback(async () => {
const result = await downloadTaxInvoiceExcel({
division: activeTab,
dateType,
startDate,
endDate,
vendorSearch,
});
if (result.success && result.data) {
window.open(result.data.url, '_blank');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
try {
toast.info('엑셀 파일 생성 중...');
const allData: TaxInvoiceMgmtRecord[] = [];
let page = 1;
let lastPage = 1;
do {
const result = await getTaxInvoices({
division: activeTab,
dateType,
startDate,
endDate,
vendorSearch,
page,
perPage: 100,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel({
data: allData as (TaxInvoiceMgmtRecord & Record<string, unknown>)[],
columns: excelColumns,
filename: `세금계산서_${activeTab === 'sales' ? '매출' : '매입'}`,
sheetName: activeTab === 'sales' ? '매출' : '매입',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [activeTab, dateType, startDate, endDate, vendorSearch]);

View File

@@ -26,8 +26,9 @@ import {
type StatCard,
} from '@/components/templates/UniversalListPage';
import type { VendorLedgerItem, VendorLedgerSummary } from './types';
import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions';
import { getVendorLedgerList, getVendorLedgerSummary } from './actions';
import { formatNumber } from '@/lib/utils/amount';
import { downloadExcel, type ExcelColumn } from '@/lib/utils/excel-download';
import { toast } from 'sonner';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
@@ -43,6 +44,16 @@ const tableColumns = [
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true },
];
// ===== 엑셀 컬럼 정의 =====
const excelColumns: ExcelColumn<VendorLedgerItem & Record<string, unknown>>[] = [
{ header: '거래처명', key: 'vendorName', width: 20 },
{ header: '이월잔액', key: 'carryoverBalance', width: 14 },
{ header: '매출', key: 'sales', width: 14 },
{ header: '수금', key: 'collection', width: 14 },
{ header: '잔액', key: 'balance', width: 14 },
{ header: '결제일', key: 'paymentDate', width: 12 },
];
// ===== Props =====
interface VendorLedgerProps {
initialData?: VendorLedgerItem[];
@@ -144,24 +155,42 @@ export function VendorLedger({
);
const handleExcelDownload = useCallback(async () => {
const result = await exportVendorLedgerExcel({
startDate,
endDate,
search: searchQuery || undefined,
});
try {
toast.info('엑셀 파일 생성 중...');
const allData: VendorLedgerItem[] = [];
let page = 1;
let lastPage = 1;
if (result.success && result.data) {
const url = URL.createObjectURL(result.data);
const a = document.createElement('a');
a.href = url;
a.download = result.filename || '거래처원장.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success('엑셀 파일이 다운로드되었습니다.');
} else {
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
do {
const result = await getVendorLedgerList({
startDate,
endDate,
search: searchQuery || undefined,
perPage: 100,
page,
});
if (result.success && result.data.length > 0) {
allData.push(...result.data);
lastPage = result.pagination?.lastPage ?? 1;
} else {
break;
}
page++;
} while (page <= lastPage);
if (allData.length > 0) {
await downloadExcel<VendorLedgerItem & Record<string, unknown>>({
data: allData as (VendorLedgerItem & Record<string, unknown>)[],
columns: excelColumns,
filename: '거래처원장',
sheetName: '거래처원장',
});
toast.success('엑셀 다운로드 완료');
} else {
toast.warning('다운로드할 데이터가 없습니다.');
}
} catch {
toast.error('엑셀 다운로드에 실패했습니다.');
}
}, [startDate, endDate, searchQuery]);

View File

@@ -10,12 +10,6 @@ import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetai
import { vendorConfig } from './vendorConfig';
import { CreditAnalysisModal, MOCK_CREDIT_DATA } from './CreditAnalysisModal';
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
businessNumber: '사업자등록번호',
vendorName: '거래처명',
category: '거래처 유형',
};
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -29,7 +23,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
// 새 입력 컴포넌트
import { PhoneInput } from '@/components/ui/phone-input';
import { BusinessNumberInput } from '@/components/ui/business-number-input';
@@ -186,13 +179,25 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
}
setValidationErrors(errors);
if (Object.keys(errors).length > 0) {
const firstError = Object.values(errors)[0];
toast.error(firstError);
}
return Object.keys(errors).length === 0;
}, [formData.businessNumber, formData.vendorName, formData.category]);
// 필드 변경 핸들러
const handleChange = useCallback((field: string, value: string | number | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// 에러 클리어
if (validationErrors[field]) {
setValidationErrors(prev => {
const next = { ...prev };
delete next[field];
return next;
});
}
}, [validationErrors]);
// 파일 검증 및 추가
const validateAndAddFiles = useCallback((files: FileList | File[]) => {
@@ -265,7 +270,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
// 저장 핸들러 (IntegratedDetailTemplate용)
const handleSubmit = useCallback(async () => {
if (!validateForm()) {
window.scrollTo({ top: 0, behavior: 'smooth' });
return { success: false, error: '입력 내용을 확인해주세요.' };
}
@@ -326,8 +330,9 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
onChange={(e) => handleChange(field, type === 'number' ? Number(e.target.value) : e.target.value)}
placeholder={placeholder}
disabled={isViewMode || disabled}
className="bg-white"
className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}
/>
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
</div>
);
};
@@ -350,7 +355,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
onValueChange={(val) => handleChange(field, val)}
disabled={isViewMode}
>
<SelectTrigger className="bg-white">
<SelectTrigger className={`bg-white ${validationErrors[field] ? 'border-red-500' : ''}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
@@ -361,6 +366,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
))}
</SelectContent>
</Select>
{validationErrors[field] && <p className="text-sm text-red-500">{validationErrors[field]}</p>}
</div>
);
};
@@ -368,35 +374,6 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
// 폼 콘텐츠 렌더링 (View/Edit 공통)
const renderFormContent = () => (
<div className="space-y-6">
{/* Validation 에러 표시 */}
{Object.keys(validationErrors).length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({Object.keys(validationErrors).length} )
</strong>
<ul className="space-y-1 text-sm">
{Object.entries(validationErrors).map(([field, message]) => {
const fieldName = FIELD_NAME_MAP[field] || field;
return (
<li key={field} className="flex items-start gap-1">
<span></span>
<span>
<strong>{fieldName}</strong>: {message}
</span>
</li>
);
})}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 기본 정보 */}
<Card>
<CardHeader>
@@ -496,6 +473,7 @@ export function VendorDetail({ mode, vendorId, openModal }: VendorDetailProps) {
showValidation={!isViewMode}
error={!!validationErrors.businessNumber}
/>
{validationErrors.businessNumber && <p className="text-sm text-red-500">{validationErrors.businessNumber}</p>}
</div>
{renderField('거래처코드', 'vendorCode', formData.vendorCode, { placeholder: '자동생성', disabled: true })}
{renderField('거래처명', 'vendorName', formData.vendorName, { required: true })}

View File

@@ -129,6 +129,11 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
enumFilter('creditRating', creditRatingFilter),
enumFilter('transactionGrade', transactionGradeFilter),
enumFilter('badDebtStatus', badDebtFilter),
(items: Vendor[]) => items.filter((item) => {
if (!item.createdAt) return true;
const created = item.createdAt.slice(0, 10);
return created >= startDate && created <= endDate;
}),
]);
// 정렬
@@ -154,7 +159,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana
}
return result;
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]);
}, [data, searchQuery, categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption, startDate, endDate]);
const paginatedData = useMemo(() => {
const startIndex = (currentPage - 1) * itemsPerPage;

View File

@@ -131,6 +131,8 @@ export async function getClients(params?: {
size?: number;
q?: string;
only_active?: boolean;
start_date?: string;
end_date?: string;
}): Promise<{ success: boolean; data: Vendor[]; total: number; error?: string }> {
const result = await executeServerAction({
url: buildApiUrl('/api/v1/clients', {
@@ -138,6 +140,8 @@ export async function getClients(params?: {
size: params?.size,
q: params?.q,
only_active: params?.only_active,
start_date: params?.start_date,
end_date: params?.end_date,
}),
transform: (data: PaginatedResponse<ClientApiData>) => ({
items: data.data.map(transformApiToFrontend),

View File

@@ -149,6 +149,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
dateField: 'createdAt',
},
// 데이터 변경 콜백 (Stats 계산용)

View File

@@ -70,7 +70,7 @@ function mapTabToApiStatus(tabStatus: string): string | undefined {
function mapApprovalType(formCategory?: string): ApprovalType {
const typeMap: Record<string, ApprovalType> = {
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate',
'expense_report': 'expense_report', 'proposal': 'proposal', 'expense_estimate': 'expense_estimate', '문서': 'document', 'document': 'document',
};
return typeMap[formCategory || ''] || 'proposal';
}
@@ -113,6 +113,7 @@ function transformApiToFrontend(data: InboxApiData): ApprovalRecord {
export async function getInbox(params?: {
page?: number; per_page?: number; search?: string; status?: string;
approval_type?: string; sort_by?: string; sort_dir?: 'asc' | 'desc';
start_date?: string; end_date?: string;
}): Promise<{ data: ApprovalRecord[]; total: number; lastPage: number; __authError?: boolean }> {
const result = await executeServerAction<PaginatedApiResponse<InboxApiData>>({
url: buildApiUrl('/api/v1/approvals/inbox', {
@@ -123,6 +124,8 @@ export async function getInbox(params?: {
approval_type: params?.approval_type !== 'all' ? params?.approval_type : undefined,
sort_by: params?.sort_by,
sort_dir: params?.sort_dir,
start_date: params?.start_date,
end_date: params?.end_date,
}),
errorMessage: '결재함 목록 조회에 실패했습니다.',
});
@@ -176,6 +179,127 @@ export async function approveDocumentsBulk(ids: string[], comment?: string): Pro
return { success: true };
}
// ============================================
// 연결 문서(Document) 조회
// ============================================
interface LinkedDocumentApiData {
id: number;
document_number: string;
title: string;
status: string;
drafter?: {
id: number; name: string; position?: string;
department?: { name: string };
tenant_profile?: { position_key?: string; department?: { name: string } };
};
steps?: InboxStepApiData[];
linkable?: {
id: number;
title: string;
document_no: string;
status: string;
created_at: string;
linkable_type?: string;
linkable_id?: number;
template?: { id: number; name: string; code: string };
data?: Array<{ id: number; field_key: string; field_label?: string; field_value?: unknown; value?: unknown }>;
approvals?: Array<{ id: number; step: number; status: string; acted_at?: string; user?: { id: number; name: string } }>;
attachments?: Array<{ id: number; display_name: string; file_path: string }>;
};
}
interface LinkedDocumentResult {
documentNo: string;
createdAt: string;
title: string;
templateName: string;
templateCode: string;
status: string;
workOrderId?: number;
documentData: Array<{ fieldKey: string; fieldLabel: string; value: unknown }>;
approvers: Array<{ id: string; name: string; position: string; department: string; status: 'pending' | 'approved' | 'rejected' | 'none' }>;
drafter: { id: string; name: string; position: string; department: string; status: 'approved' | 'pending' | 'rejected' | 'none' };
attachments?: Array<{ id: number; name: string; url: string }>;
}
function getPositionLabel(positionKey: string | null | undefined): string {
if (!positionKey) return '';
const labels: Record<string, string> = {
'EXECUTIVE': '임원', 'DIRECTOR': '부장', 'MANAGER': '과장',
'SENIOR': '대리', 'STAFF': '사원', 'INTERN': '인턴',
};
return labels[positionKey] ?? positionKey;
}
export async function getDocumentApprovalById(id: number): Promise<{
success: boolean;
data?: LinkedDocumentResult;
error?: string;
}> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = await executeServerAction<any>({
url: buildApiUrl(`/api/v1/approvals/${id}`),
errorMessage: '문서 조회에 실패했습니다.',
});
if (!result.success || !result.data) return { success: false, error: result.error };
const apiData = result.data as LinkedDocumentApiData;
const linkable = apiData.linkable;
const drafter = {
id: String(apiData.drafter?.id || ''),
name: apiData.drafter?.name || '',
position: apiData.drafter?.tenant_profile?.position_key
? getPositionLabel(apiData.drafter.tenant_profile.position_key)
: (apiData.drafter?.position || ''),
department: apiData.drafter?.tenant_profile?.department?.name
|| apiData.drafter?.department?.name || '',
status: 'approved' as const,
};
const approvers = (apiData.steps || [])
.filter(s => s.step_type === 'approval' || s.step_type === 'agreement')
.map(step => ({
id: String(step.approver?.id || step.approver_id),
name: step.approver?.name || '',
position: step.approver?.position || '',
department: step.approver?.department?.name || '',
status: (step.status === 'approved' ? 'approved'
: step.status === 'rejected' ? 'rejected'
: step.status === 'pending' ? 'pending'
: 'none') as 'pending' | 'approved' | 'rejected' | 'none',
}));
// work_order 연결 문서인 경우 workOrderId 추출
const workOrderId = linkable?.linkable_type === 'work_order' ? linkable.linkable_id : undefined;
return {
success: true,
data: {
documentNo: linkable?.document_no || apiData.document_number,
createdAt: linkable?.created_at || '',
title: linkable?.title || apiData.title,
templateName: linkable?.template?.name || '',
templateCode: linkable?.template?.code || '',
status: linkable?.status || apiData.status,
workOrderId,
documentData: (linkable?.data || []).map(d => ({
fieldKey: d.field_key,
fieldLabel: d.field_label || d.field_key,
value: d.field_value ?? d.value,
})),
approvers,
drafter,
attachments: (linkable?.attachments || []).map(a => ({
id: a.id,
name: a.display_name,
url: `/api/proxy/files/${a.id}/download`,
})),
},
};
}
export async function rejectDocumentsBulk(ids: string[], comment: string): Promise<{ success: boolean; failedIds?: string[]; error?: string }> {
if (!comment?.trim()) return { success: false, error: '반려 사유를 입력해주세요.' };
const failedIds: string[] = [];

View File

@@ -19,6 +19,7 @@ import {
rejectDocument,
approveDocumentsBulk,
rejectDocumentsBulk,
getDocumentApprovalById,
} from './actions';
import { getApprovalById } from '@/components/approval/DocumentCreate/actions';
import { Button } from '@/components/ui/button';
@@ -58,6 +59,7 @@ import type {
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
LinkedDocumentData,
} from '@/components/approval/DocumentDetail/types';
import type {
ApprovalTabType,
@@ -76,6 +78,7 @@ import {
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { usePermission } from '@/hooks/usePermission';
import { InspectionReportModal } from '@/components/production/WorkOrders/documents/InspectionReportModal';
// ===== 통계 타입 =====
interface InboxSummary {
@@ -111,9 +114,13 @@ export function ApprovalBox() {
// ===== 문서 상세 모달 상태 =====
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedDocument, setSelectedDocument] = useState<ApprovalRecord | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | null>(null);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData | null>(null);
const [isModalLoading, setIsModalLoading] = useState(false);
// ===== 검사성적서 모달 상태 (work_order 연결 문서용) =====
const [isInspectionModalOpen, setIsInspectionModalOpen] = useState(false);
const [inspectionWorkOrderId, setInspectionWorkOrderId] = useState<string | null>(null);
// API 데이터
const [data, setData] = useState<ApprovalRecord[]>([]);
const [totalCount, setTotalCount] = useState(0);
@@ -151,6 +158,8 @@ export function ApprovalBox() {
search: searchQuery || undefined,
status: activeTab !== 'all' ? activeTab : undefined,
approval_type: filterOption !== 'all' ? filterOption : undefined,
start_date: startDate || undefined,
end_date: endDate || undefined,
...sortConfig,
});
@@ -165,7 +174,7 @@ export function ApprovalBox() {
setIsLoading(false);
isInitialLoadDone.current = true;
}
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]);
}, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab, startDate, endDate]);
// ===== 초기 로드 =====
useEffect(() => {
@@ -288,6 +297,27 @@ export function ApprovalBox() {
setIsModalOpen(true);
try {
// 문서 결재(document) 타입은 별도 API로 연결 문서 데이터 조회
if (item.approvalType === 'document') {
const result = await getDocumentApprovalById(parseInt(item.id));
if (result.success && result.data) {
// work_order 연결 문서 → InspectionReportModal로 열기
if (result.data.workOrderId) {
setIsModalOpen(false);
setIsModalLoading(false);
setInspectionWorkOrderId(String(result.data.workOrderId));
setIsInspectionModalOpen(true);
return;
}
setModalData(result.data as LinkedDocumentData);
} else {
toast.error(result.error || '문서 조회에 실패했습니다.');
setIsModalOpen(false);
}
return;
}
// 기존 결재 문서 타입 (품의서, 지출결의서, 지출예상내역서)
const result = await getApprovalById(parseInt(item.id));
if (result.success && result.data) {
const formData = result.data;
@@ -439,6 +469,8 @@ export function ApprovalBox() {
return 'expenseEstimate';
case 'expense_report':
return 'expenseReport';
case 'document':
return 'document';
default:
return 'proposal';
}
@@ -514,7 +546,7 @@ export function ApprovalBox() {
dateRangeSelector: {
enabled: true,
showPresets: false,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
@@ -796,6 +828,19 @@ export function ApprovalBox() {
onReject={canApprove ? handleModalReject : undefined}
/>
)}
{/* 검사성적서 모달 (work_order 연결 문서) */}
<InspectionReportModal
open={isInspectionModalOpen}
onOpenChange={(open) => {
setIsInspectionModalOpen(open);
if (!open) {
setInspectionWorkOrderId(null);
}
}}
workOrderId={inspectionWorkOrderId}
readOnly={true}
/>
</>
),
}),
@@ -827,6 +872,8 @@ export function ApprovalBox() {
handleModalApprove,
handleModalReject,
canApprove,
isInspectionModalOpen,
inspectionWorkOrderId,
]
);

View File

@@ -9,17 +9,18 @@ export type ApprovalTabType = 'all' | 'pending' | 'approved' | 'rejected';
// 결재 상태
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate';
// 결재 유형 (문서 종류): 지출결의서, 품의서, 지출예상내역서, 문서결재
export type ApprovalType = 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
// 필터 옵션
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate';
export type FilterOption = 'all' | 'expense_report' | 'proposal' | 'expense_estimate' | 'document';
export const FILTER_OPTIONS: { value: FilterOption; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'expense_report', label: '지출결의서' },
{ value: 'proposal', label: '품의서' },
{ value: 'expense_estimate', label: '지출예상내역서' },
{ value: 'document', label: '문서 결재' },
];
// 정렬 옵션
@@ -71,12 +72,14 @@ export const APPROVAL_TYPE_LABELS: Record<ApprovalType, string> = {
expense_report: '지출결의서',
proposal: '품의서',
expense_estimate: '지출예상내역서',
document: '문서 결재',
};
export const APPROVAL_TYPE_COLORS: Record<ApprovalType, string> = {
expense_report: 'blue',
proposal: 'green',
expense_estimate: 'purple',
document: 'orange',
};
export const APPROVAL_STATUS_LABELS: Record<ApprovalStatus, string> = {

View File

@@ -4,12 +4,14 @@ import { DocumentViewer } from '@/components/document-system';
import { ProposalDocument } from './ProposalDocument';
import { ExpenseReportDocument } from './ExpenseReportDocument';
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
import { LinkedDocumentContent } from './LinkedDocumentContent';
import type {
DocumentType,
DocumentDetailModalProps,
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
LinkedDocumentData,
} from './types';
/**
@@ -41,6 +43,8 @@ export function DocumentDetailModalV2({
return '지출결의서';
case 'expenseEstimate':
return '지출 예상 내역서';
case 'document':
return (data as LinkedDocumentData).templateName || '문서 결재';
default:
return '문서';
}
@@ -69,6 +73,8 @@ export function DocumentDetailModalV2({
return <ExpenseReportDocument data={data as ExpenseReportDocumentData} />;
case 'expenseEstimate':
return <ExpenseEstimateDocument data={data as ExpenseEstimateDocumentData} />;
case 'document':
return <LinkedDocumentContent data={data as LinkedDocumentData} />;
default:
return null;
}

View File

@@ -0,0 +1,133 @@
'use client';
/**
* 연결 문서 콘텐츠 컴포넌트
*
* 문서관리에서 생성된 검사 성적서, 작업일지 등을
* 결재함 모달에서 렌더링합니다.
*/
import { ApprovalLineBox } from './ApprovalLineBox';
import type { LinkedDocumentData } from './types';
import { DocumentHeader } from '@/components/document-system';
import { Badge } from '@/components/ui/badge';
import { FileText, Paperclip } from 'lucide-react';
interface LinkedDocumentContentProps {
data: LinkedDocumentData;
}
const STATUS_LABELS: Record<string, string> = {
DRAFT: '임시저장',
PENDING: '진행중',
APPROVED: '승인완료',
REJECTED: '반려',
CANCELLED: '회수',
};
const STATUS_COLORS: Record<string, string> = {
DRAFT: 'bg-gray-100 text-gray-800',
PENDING: 'bg-yellow-100 text-yellow-800',
APPROVED: 'bg-green-100 text-green-800',
REJECTED: 'bg-red-100 text-red-800',
CANCELLED: 'bg-gray-100 text-gray-600',
};
export function LinkedDocumentContent({ data }: LinkedDocumentContentProps) {
return (
<div className="bg-white p-8 min-h-full">
{/* 문서 헤더 (공통 컴포넌트) */}
<DocumentHeader
title={data.templateName || '문서 결재'}
subtitle={`문서번호: ${data.documentNo} | 작성일자: ${data.createdAt?.substring(0, 10) || '-'}`}
layout="centered"
customApproval={<ApprovalLineBox drafter={data.drafter} approvers={data.approvers} />}
/>
{/* 문서 기본 정보 */}
<div className="border border-gray-300 mb-4">
<div className="flex border-b border-gray-300">
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
</div>
<div className="flex-1 p-3 text-sm">{data.title || '-'}</div>
</div>
<div className="grid grid-cols-2 border-b border-gray-300">
<div className="flex border-r border-gray-300">
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
</div>
<div className="flex-1 p-3 text-sm flex items-center gap-2">
<FileText className="w-4 h-4 text-gray-500" />
{data.templateName || '-'}
</div>
</div>
<div className="flex">
<div className="w-28 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
</div>
<div className="flex-1 p-3 text-sm">
<Badge className={STATUS_COLORS[data.status] || 'bg-gray-100 text-gray-800'}>
{STATUS_LABELS[data.status] || data.status}
</Badge>
</div>
</div>
</div>
</div>
{/* 문서 데이터 */}
{data.documentData.length > 0 && (
<div className="border border-gray-300 mb-4">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="divide-y divide-gray-300">
{data.documentData.map((field, index) => (
<div key={index} className="flex">
<div className="w-36 bg-gray-100 p-3 font-medium text-sm border-r border-gray-300">
{field.fieldLabel}
</div>
<div className="flex-1 p-3 text-sm whitespace-pre-wrap">
{renderFieldValue(field.value)}
</div>
</div>
))}
</div>
</div>
)}
{/* 첨부파일 */}
{data.attachments && data.attachments.length > 0 && (
<div className="border border-gray-300">
<div className="bg-gray-800 text-white p-2 text-sm font-medium text-center">
</div>
<div className="p-4 space-y-2">
{data.attachments.map((file) => (
<a
key={file.id}
href={file.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 text-sm text-blue-600 hover:underline"
>
<Paperclip className="w-4 h-4" />
{file.name}
</a>
))}
</div>
</div>
)}
</div>
);
}
function renderFieldValue(value: unknown): string {
if (value === null || value === undefined) return '-';
if (typeof value === 'string') return value || '-';
if (typeof value === 'number') return String(value);
if (typeof value === 'boolean') return value ? '예' : '아니오';
if (Array.isArray(value)) return value.join(', ') || '-';
if (typeof value === 'object') return JSON.stringify(value, null, 2);
return String(value);
}

View File

@@ -1,6 +1,6 @@
// ===== 문서 상세 모달 타입 정의 =====
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate';
export type DocumentType = 'proposal' | 'expenseReport' | 'expenseEstimate' | 'document';
// 결재자 정보
export interface Approver {
@@ -72,6 +72,29 @@ export interface ExpenseEstimateDocumentData {
drafter: Approver;
}
// 연결 문서 데이터 (검사 성적서, 작업일지 등 문서관리에서 생성된 문서)
export interface LinkedDocumentData {
documentNo: string;
createdAt: string;
title: string;
templateName: string;
templateCode: string;
status: string;
workOrderId?: number;
documentData: Array<{
fieldKey: string;
fieldLabel: string;
value: unknown;
}>;
approvers: Approver[];
drafter: Approver;
attachments?: Array<{
id: number;
name: string;
url: string;
}>;
}
// 문서 상세 모달 모드
export type DocumentDetailMode = 'draft' | 'inbox' | 'reference';
@@ -83,7 +106,7 @@ export interface DocumentDetailModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
documentType: DocumentType;
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData;
data: ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | LinkedDocumentData;
mode?: DocumentDetailMode; // 'draft': 기안함 (상신), 'inbox': 결재함 (승인/반려)
documentStatus?: DocumentStatus; // 문서 상태 (임시저장일 때만 수정/상신 가능)
onEdit?: () => void;

View File

@@ -1,6 +1,6 @@
'use client';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useState, useCallback, useEffect, useMemo, type RefCallback } from 'react';
import { useRouter } from 'next/navigation';
import { LayoutDashboard, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -32,26 +32,27 @@ import type { CEODashboardData, CalendarScheduleItem, DashboardSettings, DetailM
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER } from './types';
import { ScheduleDetailModal, DetailModal } from './modals';
import { DashboardSettingsDialog } from './dialogs/DashboardSettingsDialog';
import { mockData } from './mockData';
import { LazySection } from './LazySection';
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useWelfare, useWelfareDetail, useMonthlyExpenseDetail } from '@/hooks/useCEODashboard';
import { useCardManagementModals, type CardManagementCardId } from '@/hooks/useCardManagementModals';
import type { MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
import {
getMonthlyExpenseModalConfig,
getCardManagementModalConfig,
getCardManagementModalConfigWithData,
getEntertainmentModalConfig,
getWelfareModalConfig,
getVatModalConfig,
} from './modalConfigs';
import { EmptySection } from './components';
import { SummaryNavBar } from './SummaryNavBar';
import { useSectionSummary } from './useSectionSummary';
import { useCEODashboard, useTodayIssue, useCalendar, useVat, useEntertainment, useEntertainmentDetail, useWelfare, useWelfareDetail, useVatDetail, useMonthlyExpenseDetail, type MonthlyExpenseCardId } from '@/hooks/useCEODashboard';
import { useCardManagementModals } from '@/hooks/useCardManagementModals';
import { getCardManagementModalConfigWithData } from './modalConfigs';
import { transformEntertainmentDetailResponse, transformWelfareDetailResponse, transformVatDetailResponse } from '@/lib/api/dashboard/transformers';
import { toast } from 'sonner';
export function CEODashboard() {
const router = useRouter();
// API 데이터 Hook (Phase 1 섹션들)
// API 데이터 Hook
const apiData = useCEODashboard({
cardManagementFallback: mockData.cardManagement,
salesStatus: true,
purchaseStatus: true,
dailyProduction: true,
unshipped: true,
construction: true,
dailyAttendance: true,
});
// TodayIssue API Hook (Phase 2)
@@ -81,6 +82,12 @@ export function CEODashboard() {
apiData.monthlyExpense.loading ||
apiData.cardManagement.loading ||
apiData.statusBoard.loading ||
apiData.salesStatus.loading ||
apiData.purchaseStatus.loading ||
apiData.dailyProduction.loading ||
apiData.unshipped.loading ||
apiData.construction.loading ||
apiData.dailyAttendance.loading ||
todayIssueData.loading ||
calendarData.loading ||
vatData.loading ||
@@ -89,30 +96,37 @@ export function CEODashboard() {
);
}, [apiData, todayIssueData.loading, calendarData.loading, vatData.loading, entertainmentData.loading, welfareData.loading]);
// API 데이터와 mockData를 병합 (API 우선, 실패 시 fallback)
// API 데이터만으로 구성 (mock 제거 — API 미응답 시 undefined → 빈 상태 UI)
const data = useMemo<CEODashboardData>(() => ({
...mockData,
// Phase 1 섹션들: API 데이터 우선, 실패 시 mockData fallback
// TODO: 자금현황 카드 변경 (일일일보/매출채권/매입채무/운영자금) - 새 API 구현 후 교체
dailyReport: mockData.dailyReport,
receivable: apiData.receivable.data ?? mockData.receivable,
debtCollection: apiData.debtCollection.data ?? mockData.debtCollection,
monthlyExpense: apiData.monthlyExpense.data ?? mockData.monthlyExpense,
cardManagement: apiData.cardManagement.data ?? mockData.cardManagement,
// Phase 2 섹션들 (API 연동 완료 - 목업 fallback 제거)
todayIssue: apiData.statusBoard.data ?? [],
todayIssueList: todayIssueData.data?.items ?? [],
calendarSchedules: calendarData.data?.items ?? mockData.calendarSchedules,
vat: vatData.data ?? mockData.vat,
entertainment: entertainmentData.data ?? mockData.entertainment,
welfare: welfareData.data ?? mockData.welfare,
// 신규 섹션 (API 미구현 - mock 데이터)
salesStatus: mockData.salesStatus,
purchaseStatus: mockData.purchaseStatus,
dailyProduction: mockData.dailyProduction,
unshipped: mockData.unshipped,
dailyAttendance: mockData.dailyAttendance,
}), [apiData, todayIssueData.data, calendarData.data, vatData.data, entertainmentData.data, welfareData.data, mockData]);
dailyReport: apiData.dailyReport.data ?? undefined,
monthlyExpense: apiData.monthlyExpense.data ?? undefined,
cardManagement: apiData.cardManagement.data ?? undefined,
entertainment: entertainmentData.data ?? undefined,
welfare: welfareData.data ?? undefined,
receivable: apiData.receivable.data ?? undefined,
debtCollection: apiData.debtCollection.data ?? undefined,
vat: vatData.data ?? undefined,
calendarSchedules: calendarData.data?.items ?? undefined,
salesStatus: apiData.salesStatus.data ?? {
cumulativeSales: 0, achievementRate: 0, yoyChange: 0, monthlySales: 0,
monthlyTrend: [], clientSales: [], dailyItems: [], dailyTotal: 0,
},
purchaseStatus: apiData.purchaseStatus.data ?? {
cumulativePurchase: 0, unpaidAmount: 0, yoyChange: 0,
monthlyTrend: [], materialRatio: [], dailyItems: [], dailyTotal: 0,
},
dailyProduction: apiData.dailyProduction.data ?? {
date: '', processes: [],
shipment: { expectedAmount: 0, expectedCount: 0, actualAmount: 0, actualCount: 0 },
},
unshipped: apiData.unshipped.data ?? { items: [] },
constructionData: apiData.construction.data ?? { thisMonth: 0, completed: 0, items: [] },
dailyAttendance: apiData.dailyAttendance.data ?? {
present: 0, onLeave: 0, late: 0, absent: 0, employees: [],
},
}), [apiData, todayIssueData.data, calendarData.data, vatData.data, entertainmentData.data, welfareData.data]);
// 일정 상세 모달 상태
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
@@ -122,17 +136,24 @@ export function CEODashboard() {
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [dashboardSettings, setDashboardSettings] = useState<DashboardSettings>(DEFAULT_DASHBOARD_SETTINGS);
// EntertainmentDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
const entertainmentDetailData = useEntertainmentDetail();
// WelfareDetail Hook (모달용 상세 API) - dashboardSettings 이후에 선언
const welfareDetailData = useWelfareDetail({
calculationType: dashboardSettings.welfare.calculationType,
});
// VatDetail Hook (부가세 상세 모달용 API)
const vatDetailData = useVatDetail();
// MonthlyExpenseDetail Hook (당월 예상 지출 모달용 상세 API)
const monthlyExpenseDetailData = useMonthlyExpenseDetail();
// 상세 모달 상태
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
const [detailModalConfig, setDetailModalConfig] = useState<DetailModalConfig | null>(null);
const [currentModalCardId, setCurrentModalCardId] = useState<string | null>(null);
// 클라이언트에서만 localStorage에서 설정 불러오기 (hydration 에러 방지)
useEffect(() => {
@@ -201,66 +222,174 @@ export function CEODashboard() {
const handleDetailModalClose = useCallback(() => {
setIsDetailModalOpen(false);
setDetailModalConfig(null);
setCurrentModalCardId(null);
}, []);
// 당월 예상 지출 카드 클릭 (개별 카드 클릭 시 상세 모달)
// 당월 예상 지출 카드 클릭 - API 데이터로 모달 열기
const handleMonthlyExpenseCardClick = useCallback(async (cardId: string) => {
// 1. 먼저 API에서 데이터 fetch 시도
const apiConfig = await monthlyExpenseDetailData.fetchData(cardId as MonthlyExpenseCardId);
// 2. API 데이터가 있으면 사용, 없으면 fallback config 사용
const config = apiConfig ?? getMonthlyExpenseModalConfig(cardId);
const config = await monthlyExpenseDetailData.fetchData(cardId as MonthlyExpenseCardId);
if (config) {
setCurrentModalCardId(cardId);
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}
}, [monthlyExpenseDetailData]);
// 모달 날짜/검색 필터 변경 → 재조회 (당월 예상 지출 + 가지급금 + 접대비 상세)
const handleDateFilterChange = useCallback(async (params: { startDate: string; endDate: string; search: string }) => {
if (!currentModalCardId) return;
// cm2: 가지급금 상세 모달 날짜 필터
if (currentModalCardId === 'cm2') {
try {
const modalData = await cardManagementModals.fetchModalData('cm2', {
start_date: params.startDate,
end_date: params.endDate,
});
const config = getCardManagementModalConfigWithData('cm2', modalData);
if (config) {
setDetailModalConfig(config);
}
} catch {
// 실패 시 기존 config 유지
}
return;
}
// 복리후생비 상세 모달 날짜 필터
if (currentModalCardId === 'welfare_detail') {
try {
const response = await fetch(
`/api/proxy/welfare/detail?calculation_type=${dashboardSettings.welfare.calculationType}&start_date=${params.startDate}&end_date=${params.endDate}`,
);
if (response.ok) {
const result = await response.json();
if (result.success) {
const config = transformWelfareDetailResponse(result.data);
setDetailModalConfig(config);
}
}
} catch {
// 실패 시 기존 config 유지
}
return;
}
// 접대비 상세 모달 날짜 필터
if (currentModalCardId === 'entertainment_detail') {
try {
const response = await fetch(
`/api/proxy/entertainment/detail?company_type=${dashboardSettings.entertainment.companyType}&start_date=${params.startDate}&end_date=${params.endDate}`,
);
if (response.ok) {
const result = await response.json();
if (result.success) {
const config = transformEntertainmentDetailResponse(result.data);
setDetailModalConfig(config);
}
}
} catch {
// 실패 시 기존 config 유지
}
return;
}
// 당월 예상 지출 모달 날짜 필터
const config = await monthlyExpenseDetailData.fetchData(
currentModalCardId as MonthlyExpenseCardId,
params,
);
if (config) {
setDetailModalConfig(config);
}
}, [currentModalCardId, monthlyExpenseDetailData, cardManagementModals, dashboardSettings.entertainment, dashboardSettings.welfare]);
// 당월 예상 지출 클릭 (deprecated - 개별 카드 클릭으로 대체)
const handleMonthlyExpenseClick = useCallback(() => {
}, []);
// 카드/가지급금 관리 카드 클릭 (개별 카드 클릭 시 상세 모달)
// 카드/가지급금 관리 카드 클릭 → 모두 가지급금 상세(cm2) 모달
// 기획서 P52: 카드, 경조사, 상품권, 접대비, 총합계 모두 동일한 가지급금 상세 모달
const handleCardManagementCardClick = useCallback(async (cardId: string) => {
// 1. API에서 데이터 fetch (데이터 직접 반환)
const modalData = await cardManagementModals.fetchModalData(cardId as CardManagementCardId);
// 2. API 데이터로 config 생성 (데이터 없으면 fallback)
const config = getCardManagementModalConfigWithData(cardId, modalData);
if (config) {
setDetailModalConfig(config);
setIsDetailModalOpen(true);
try {
const modalData = await cardManagementModals.fetchModalData('cm2');
const config = getCardManagementModalConfigWithData('cm2', modalData);
if (config) {
setCurrentModalCardId('cm2');
setDetailModalConfig(config);
setIsDetailModalOpen(true);
} else {
toast.error('데이터를 불러올 수 없습니다');
}
} catch {
toast.error('데이터를 불러올 수 없습니다');
}
}, [cardManagementModals]);
// 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달)
const handleEntertainmentCardClick = useCallback((cardId: string) => {
const config = getEntertainmentModalConfig(cardId);
if (config) {
setDetailModalConfig(config);
// 접대비 현황 카드 클릭 - API 데이터로 모달 열기
const handleEntertainmentCardClick = useCallback(async (cardId: string) => {
setCurrentModalCardId('entertainment_detail');
const apiConfig = await entertainmentDetailData.refetch();
if (apiConfig) {
setDetailModalConfig(apiConfig);
setIsDetailModalOpen(true);
} else {
toast.error('데이터를 불러올 수 없습니다');
}
}, [entertainmentDetailData]);
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
const handleWelfareCardClick = useCallback(async () => {
const apiConfig = await welfareDetailData.refetch();
if (apiConfig) {
setDetailModalConfig(apiConfig);
setCurrentModalCardId('welfare_detail');
setIsDetailModalOpen(true);
} else {
toast.error('데이터를 불러올 수 없습니다');
}
}, [welfareDetailData]);
// 신고기간 변경 시 API 재호출
const handlePeriodChange = useCallback(async (periodValue: string) => {
// periodValue: "2026-quarter-1" → parse
const parts = periodValue.split('-');
if (parts.length < 3) return;
const [year, periodType, period] = parts;
try {
const response = await fetch(
`/api/proxy/vat/detail?period_type=${periodType}&year=${year}&period=${period}`,
);
if (response.ok) {
const result = await response.json();
if (result.success) {
const config = transformVatDetailResponse(result.data);
// 새 config에도 onPeriodChange 콜백 주입
if (config.periodSelect) {
config.periodSelect.onPeriodChange = handlePeriodChange;
}
setDetailModalConfig(config);
}
}
} catch {
// 실패 시 기존 config 유지
}
}, []);
// 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달)
// 복리후생비 클릭 - API 데이터로 모달 열기 (fallback: 정적 config)
const handleWelfareCardClick = useCallback(async () => {
// 1. 먼저 API에서 데이터 fetch 시도
await welfareDetailData.refetch();
// 2. API 데이터가 있으면 사용, 없으면 fallback config 사용
const config = welfareDetailData.modalConfig ?? getWelfareModalConfig(dashboardSettings.welfare.calculationType);
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}, [welfareDetailData, dashboardSettings.welfare.calculationType]);
// 부가세 클릭 (모든 카드가 동일한 상세 모달)
const handleVatClick = useCallback(() => {
const config = getVatModalConfig();
setDetailModalConfig(config);
setIsDetailModalOpen(true);
}, []);
// 부가세 클릭 (모든 카드가 동일한 상세 모달) - API 데이터로 열기
const handleVatClick = useCallback(async () => {
setCurrentModalCardId('vat_detail');
const apiConfig = await vatDetailData.refetch();
if (apiConfig) {
if (apiConfig.periodSelect) {
apiConfig.periodSelect.onPeriodChange = handlePeriodChange;
}
setDetailModalConfig(apiConfig);
setIsDetailModalOpen(true);
} else {
toast.error('데이터를 불러올 수 없습니다');
}
}, [vatDetailData, handlePeriodChange]);
// 캘린더 일정 클릭 (기존 일정 수정)
const handleScheduleClick = useCallback((schedule: CalendarScheduleItem) => {
@@ -280,8 +409,8 @@ export function CEODashboard() {
setSelectedSchedule(null);
}, []);
// 일정 저장
const handleScheduleSave = useCallback((formData: {
// 일정 저장 (optimistic update — refetch 없이 로컬 상태만 갱신)
const handleScheduleSave = useCallback(async (formData: {
title: string;
department: string;
startDate: string;
@@ -292,21 +421,138 @@ export function CEODashboard() {
color: string;
content: string;
}) => {
// TODO: API 호출하여 일정 저장
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}, []);
try {
// schedule_ 접두사에서 실제 ID 추출
const rawId = selectedSchedule?.id;
const numericId = rawId?.startsWith('schedule_') ? rawId.replace('schedule_', '') : null;
// 일정 삭제
const handleScheduleDelete = useCallback((id: string) => {
// TODO: API 호출하여 일정 삭제
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}, []);
const body = {
title: formData.title,
description: formData.content,
start_date: formData.startDate,
end_date: formData.endDate,
start_time: formData.isAllDay ? null : (formData.startTime || null),
end_time: formData.isAllDay ? null : (formData.endTime || null),
is_all_day: formData.isAllDay,
color: formData.color || null,
};
const url = numericId
? `/api/proxy/calendar/schedules/${numericId}`
: '/api/proxy/calendar/schedules';
const method = numericId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) throw new Error('Failed to save schedule');
// API 응답에서 실제 ID 추출 (없으면 임시 ID)
let savedId = numericId;
try {
const result = await response.json();
savedId = result.data?.id?.toString() || numericId || `temp_${Date.now()}`;
} catch {
savedId = numericId || `temp_${Date.now()}`;
}
const updatedSchedule: CalendarScheduleItem = {
id: `schedule_${savedId}`,
title: formData.title,
startDate: formData.startDate,
endDate: formData.endDate,
startTime: formData.isAllDay ? undefined : formData.startTime,
endTime: formData.isAllDay ? undefined : formData.endTime,
isAllDay: formData.isAllDay,
type: 'schedule',
department: formData.department !== 'all' ? formData.department : undefined,
color: formData.color,
};
// Optimistic update: loading 변화 없이 데이터만 갱신 → 캘린더만 리렌더
calendarData.setData((prev) => {
if (!prev) return { items: [updatedSchedule], totalCount: 1 };
if (numericId) {
// 수정: 기존 항목 교체
return {
...prev,
items: prev.items.map((item) =>
item.id === rawId ? updatedSchedule : item
),
};
}
// 신규: 추가
return {
...prev,
items: [...prev.items, updatedSchedule],
totalCount: prev.totalCount + 1,
};
});
} catch {
// 에러 시 서버 데이터로 동기화
calendarData.refetch();
} finally {
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}
}, [selectedSchedule, calendarData]);
// 일정 삭제 (optimistic update)
const handleScheduleDelete = useCallback(async (id: string) => {
try {
// schedule_ 접두사에서 실제 ID 추출
const numericId = id.startsWith('schedule_') ? id.replace('schedule_', '') : id;
const response = await fetch(`/api/proxy/calendar/schedules/${numericId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete schedule');
// Optimistic update: 삭제된 항목만 제거 → 캘린더만 리렌더
calendarData.setData((prev) => {
if (!prev) return prev;
return {
...prev,
items: prev.items.filter((item) => item.id !== id),
totalCount: Math.max(0, prev.totalCount - 1),
};
});
} catch {
// 에러 시 서버 데이터로 동기화
calendarData.refetch();
} finally {
setIsScheduleModalOpen(false);
setSelectedSchedule(null);
}
}, [calendarData]);
// 섹션 순서
const sectionOrder = dashboardSettings.sectionOrder ?? DEFAULT_SECTION_ORDER;
// 요약 네비게이션 바 훅
const { summaries, activeSectionKey, sectionRefs, scrollToSection } = useSectionSummary({
data,
sectionOrder,
dashboardSettings,
});
// 섹션 ref 수집 콜백
const setSectionRef = useCallback(
(key: SectionKey): RefCallback<HTMLDivElement> =>
(el) => {
if (el) {
sectionRefs.current.set(key, el);
} else {
sectionRefs.current.delete(key);
}
},
[sectionRefs],
);
// 섹션 렌더링 함수
const renderDashboardSection = (key: SectionKey): React.ReactNode => {
switch (key) {
@@ -319,12 +565,13 @@ export function CEODashboard() {
);
case 'dailyReport':
if (!dashboardSettings.dailyReport) return null;
if (!dashboardSettings.dailyReport || !data.dailyReport) return null;
return (
<LazySection key={key}>
<EnhancedDailyReportSection
data={data.dailyReport}
onClick={handleDailyReportClick}
onExpenseDetailClick={() => handleMonthlyExpenseCardClick('me4')}
/>
</LazySection>
);
@@ -341,7 +588,7 @@ export function CEODashboard() {
);
case 'monthlyExpense':
if (!dashboardSettings.monthlyExpense) return null;
if (!dashboardSettings.monthlyExpense || !data.monthlyExpense) return null;
return (
<LazySection key={key}>
<EnhancedMonthlyExpenseSection
@@ -352,7 +599,7 @@ export function CEODashboard() {
);
case 'cardManagement':
if (!dashboardSettings.cardManagement) return null;
if (!dashboardSettings.cardManagement || !data.cardManagement) return null;
return (
<LazySection key={key}>
<CardManagementSection
@@ -363,7 +610,7 @@ export function CEODashboard() {
);
case 'entertainment':
if (!dashboardSettings.entertainment.enabled) return null;
if (!dashboardSettings.entertainment.enabled || !data.entertainment) return null;
return (
<LazySection key={key}>
<EntertainmentSection
@@ -374,7 +621,7 @@ export function CEODashboard() {
);
case 'welfare':
if (!dashboardSettings.welfare.enabled) return null;
if (!dashboardSettings.welfare.enabled || !data.welfare) return null;
return (
<LazySection key={key}>
<WelfareSection
@@ -385,7 +632,7 @@ export function CEODashboard() {
);
case 'receivable':
if (!dashboardSettings.receivable) return null;
if (!dashboardSettings.receivable || !data.receivable) return null;
return (
<LazySection key={key}>
<ReceivableSection data={data.receivable} />
@@ -393,7 +640,7 @@ export function CEODashboard() {
);
case 'debtCollection':
if (!dashboardSettings.debtCollection) return null;
if (!dashboardSettings.debtCollection || !data.debtCollection) return null;
return (
<LazySection key={key}>
<DebtCollectionSection data={data.debtCollection} />
@@ -401,7 +648,7 @@ export function CEODashboard() {
);
case 'vat':
if (!dashboardSettings.vat) return null;
if (!dashboardSettings.vat || !data.vat) return null;
return (
<LazySection key={key}>
<VatSection data={data.vat} onClick={handleVatClick} />
@@ -409,7 +656,7 @@ export function CEODashboard() {
);
case 'calendar':
if (!dashboardSettings.calendar) return null;
if (!dashboardSettings.calendar || !data.calendarSchedules) return null;
return (
<LazySection key={key} minHeight={500}>
<CalendarSection
@@ -520,17 +767,32 @@ export function CEODashboard() {
}
/>
<SummaryNavBar
summaries={summaries}
activeSectionKey={activeSectionKey}
onChipClick={scrollToSection}
/>
<div className="space-y-6">
{sectionOrder.map(renderDashboardSection)}
{sectionOrder.map((key) => {
const node = renderDashboardSection(key);
if (!node) return null;
return (
<div key={key} ref={setSectionRef(key)} data-section-key={key}>
{node}
</div>
);
})}
</div>
{/* 일정 상세 모달 */}
{/* 일정 상세 모달 — schedule_ 접두사만 수정/삭제 가능 */}
<ScheduleDetailModal
isOpen={isScheduleModalOpen}
onClose={handleScheduleModalClose}
schedule={selectedSchedule}
onSave={handleScheduleSave}
onDelete={handleScheduleDelete}
isEditable={!selectedSchedule?.id || selectedSchedule.id === '' || selectedSchedule.id.startsWith('schedule_')}
/>
{/* 항목 설정 모달 */}
@@ -547,6 +809,7 @@ export function CEODashboard() {
isOpen={isDetailModalOpen}
onClose={handleDetailModalClose}
config={detailModalConfig}
onDateFilterChange={handleDateFilterChange}
/>
)}
</PageLayout>

View File

@@ -0,0 +1,255 @@
'use client';
import { useRef, useEffect, useCallback, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SectionSummary, SummaryStatus } from './useSectionSummary';
import type { SectionKey } from './types';
/** 상태별 점(dot) 색상 */
const STATUS_DOT: Record<SummaryStatus, string> = {
normal: 'bg-green-500',
warning: 'bg-yellow-500',
danger: 'bg-red-500',
};
/** 상태별 칩 배경색 (비활성) */
const STATUS_BG: Record<SummaryStatus, string> = {
normal: 'bg-background border-border',
warning: 'bg-yellow-50 border-yellow-300 dark:bg-yellow-950/30 dark:border-yellow-700',
danger: 'bg-red-50 border-red-300 dark:bg-red-950/30 dark:border-red-700',
};
/** 상태별 칩 배경색 (활성) */
const STATUS_BG_ACTIVE: Record<SummaryStatus, string> = {
normal: 'bg-accent border-primary/40',
warning: 'bg-yellow-100 border-yellow-400 dark:bg-yellow-900/40 dark:border-yellow-600',
danger: 'bg-red-100 border-red-400 dark:bg-red-900/40 dark:border-red-600',
};
interface SummaryChipProps {
summary: SectionSummary;
isActive: boolean;
onClick: () => void;
}
function SummaryChip({ summary, isActive, onClick }: SummaryChipProps) {
return (
<button
type="button"
onClick={onClick}
className={cn(
'relative flex items-center gap-2 px-4 py-2.5 rounded-full text-sm font-medium whitespace-nowrap',
'border transition-all duration-200 shrink-0',
'hover:brightness-95 active:scale-[0.97]',
isActive
? cn(STATUS_BG_ACTIVE[summary.status], 'text-foreground shadow-sm')
: cn(STATUS_BG[summary.status], 'text-muted-foreground'),
)}
>
{/* 상태 점 (확대) */}
<span className={cn('w-2.5 h-2.5 rounded-full shrink-0', STATUS_DOT[summary.status])} />
{/* 라벨 */}
<span className="truncate max-w-[6rem]">{summary.label}</span>
{/* 값 */}
<span className={cn(
'font-bold',
summary.status === 'danger' && 'text-red-600 dark:text-red-400',
summary.status === 'warning' && 'text-yellow-600 dark:text-yellow-400',
)}>
{summary.value}
</span>
{/* 활성 하단 바 */}
{isActive && (
<span className="absolute bottom-0 left-3 right-3 h-[3px] rounded-full bg-primary" />
)}
</button>
);
}
const HEADER_BOTTOM = 100; // 헤더 하단 고정 위치 (px)
const BAR_HEIGHT = 56; // 요약바 높이 (px) — 고령 친화 확대
const SCROLL_STEP = 200; // 화살표 버튼 클릭 시 스크롤 이동량 (px)
interface SummaryNavBarProps {
summaries: SectionSummary[];
activeSectionKey: SectionKey | null;
onChipClick: (key: SectionKey) => void;
}
export function SummaryNavBar({ summaries, activeSectionKey, onChipClick }: SummaryNavBarProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const sentinelRef = useRef<HTMLDivElement>(null);
const [isFixed, setIsFixed] = useState(false);
const [barRect, setBarRect] = useState({ left: 0, width: 0 });
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
// 스크롤 가능 여부 체크
const updateScrollButtons = useCallback(() => {
const el = scrollRef.current;
if (!el) return;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 4);
}, []);
// sentinel 위치 감시: sentinel이 헤더 뒤로 지나가면 fixed 모드
useEffect(() => {
const handleScroll = () => {
if (!sentinelRef.current) return;
const rect = sentinelRef.current.getBoundingClientRect();
const shouldFix = rect.top < HEADER_BOTTOM;
setIsFixed(shouldFix);
if (shouldFix) {
const main = document.querySelector('main');
if (main) {
const mainRect = main.getBoundingClientRect();
const mainStyle = getComputedStyle(main);
const pl = parseFloat(mainStyle.paddingLeft) || 0;
const pr = parseFloat(mainStyle.paddingRight) || 0;
setBarRect({
left: mainRect.left + pl,
width: mainRect.width - pl - pr,
});
}
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('resize', handleScroll, { passive: true });
handleScroll();
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleScroll);
};
}, []);
// 칩 영역 스크롤 상태 감시
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
updateScrollButtons();
el.addEventListener('scroll', updateScrollButtons, { passive: true });
const ro = new ResizeObserver(updateScrollButtons);
ro.observe(el);
return () => {
el.removeEventListener('scroll', updateScrollButtons);
ro.disconnect();
};
}, [updateScrollButtons, summaries]);
// 활성 칩 자동 스크롤 into view
useEffect(() => {
if (!activeSectionKey || !scrollRef.current) return;
const chipEl = scrollRef.current.querySelector(`[data-chip-key="${activeSectionKey}"]`) as HTMLElement | null;
if (!chipEl) return;
const container = scrollRef.current;
const chipLeft = chipEl.offsetLeft;
const chipWidth = chipEl.offsetWidth;
const containerWidth = container.offsetWidth;
const scrollLeft = container.scrollLeft;
if (chipLeft < scrollLeft + 50 || chipLeft + chipWidth > scrollLeft + containerWidth - 50) {
container.scrollTo({
left: chipLeft - containerWidth / 2 + chipWidth / 2,
behavior: 'smooth',
});
}
}, [activeSectionKey]);
const handleChipClick = useCallback(
(key: SectionKey) => {
onChipClick(key);
},
[onChipClick],
);
// 화살표 버튼 핸들러
const scrollBy = useCallback((direction: 'left' | 'right') => {
const el = scrollRef.current;
if (!el) return;
el.scrollBy({
left: direction === 'left' ? -SCROLL_STEP : SCROLL_STEP,
behavior: 'smooth',
});
}, []);
if (summaries.length === 0) return null;
const arrowBtnClass = cn(
'flex items-center justify-center w-8 h-8 rounded-full shrink-0',
'bg-muted/80 hover:bg-muted text-foreground',
'border border-border shadow-sm',
'transition-opacity duration-150',
);
const barContent = (
<div className="flex items-center gap-1.5">
{/* 좌측 화살표 */}
<button
type="button"
aria-label="이전 항목"
className={cn(arrowBtnClass, !canScrollLeft && 'opacity-0 pointer-events-none')}
onClick={() => scrollBy('left')}
>
<ChevronLeft className="w-5 h-5" />
</button>
{/* 칩 목록 */}
<div
ref={scrollRef}
className="flex items-center gap-2 overflow-x-auto flex-1"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{summaries.map((s) => (
<div key={s.key} data-chip-key={s.key}>
<SummaryChip
summary={s}
isActive={activeSectionKey === s.key}
onClick={() => handleChipClick(s.key)}
/>
</div>
))}
</div>
{/* 우측 화살표 */}
<button
type="button"
aria-label="다음 항목"
className={cn(arrowBtnClass, !canScrollRight && 'opacity-0 pointer-events-none')}
onClick={() => scrollBy('right')}
>
<ChevronRight className="w-5 h-5" />
</button>
</div>
);
return (
<>
{/* sentinel: 이 div가 헤더 뒤로 사라지면 fixed 모드 활성화 */}
<div ref={sentinelRef} className="h-0 w-full" />
{/* fixed일 때 레이아웃 공간 유지용 spacer */}
{isFixed && <div style={{ height: BAR_HEIGHT }} />}
{/* 실제 바 */}
<div
className="z-40 py-2.5 backdrop-blur-md bg-background/90 border-b border-border/50"
style={
isFixed
? {
position: 'fixed',
top: HEADER_BOTTOM,
left: barRect.left,
width: barRect.width,
}
: { position: 'relative' }
}
>
{barContent}
</div>
</>
);
}

View File

@@ -37,26 +37,15 @@ export const SECTION_THEME_STYLES: Record<SectionColorTheme, { bgClass: string;
/**
* 금액 포맷 함수
*/
export const formatAmount = (amount: number, showUnit = true): string => {
const formatAmount = (amount: number, showUnit = true): string => {
const formatted = new Intl.NumberFormat('ko-KR').format(amount);
return showUnit ? formatted + '원' : formatted;
};
/**
* 억 단위 포맷 함수
*/
export const formatBillion = (amount: number): string => {
const billion = amount / 100000000;
if (billion >= 1) {
return billion.toFixed(1) + '억원';
}
return formatAmount(amount);
};
/**
* USD 달러 포맷 함수
*/
export const formatUSD = (amount: number): string => {
const formatUSD = (amount: number): string => {
return '$ ' + new Intl.NumberFormat('en-US').format(amount);
};
@@ -330,8 +319,8 @@ export const AmountCardItem = ({
<div className="mt-auto space-y-0.5 text-xs text-muted-foreground">
{card.subItems.map((item, idx) => (
<div key={idx} className="flex justify-between gap-2">
<span className="shrink-0">{item.label}</span>
<span className="text-right">{typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value}</span>
<span className="min-w-0 truncate">{item.label}</span>
<span className="shrink-0 text-right">{typeof item.value === 'number' ? formatKoreanAmount(item.value) : item.value}</span>
</div>
))}
</div>
@@ -490,4 +479,19 @@ export function CollapsibleDashboardCard({
)}
</div>
);
}
/**
* 데이터가 없거나 API 미연동 섹션에 표시하는 빈 상태 컴포넌트
*/
export function EmptySection({ title, message = '데이터를 불러올 수 없습니다' }: { title: string; message?: string }) {
return (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Info className="mb-3 h-8 w-8 opacity-40" />
<p className="text-sm font-medium">{title}</p>
<p className="mt-1 text-xs">{message}</p>
</CardContent>
</Card>
);
}

View File

@@ -1,10 +1,7 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { CurrencyInput } from '@/components/ui/currency-input';
import { NumberInput } from '@/components/ui/number-input';
import {
Dialog,
DialogContent,
@@ -12,18 +9,6 @@ import {
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type {
DashboardSettings,
@@ -34,21 +19,13 @@ import type {
WelfareCalculationType,
SectionKey,
} from '../types';
import { DEFAULT_DASHBOARD_SETTINGS, DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types';
// 현황판 항목 라벨 (구 오늘의 이슈)
const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
orders: '수주',
debtCollection: '채권 추심',
safetyStock: '안전 재고',
taxReport: '세금 신고',
newVendor: '신규 업체 등록',
annualLeave: '연차',
lateness: '지각',
absence: '결근',
purchase: '발주',
approvalRequest: '결재 요청',
};
import { DEFAULT_SECTION_ORDER, SECTION_LABELS } from '../types';
import {
SectionRow,
StatusBoardItemsList,
EntertainmentContent,
WelfareContent,
} from './DashboardSettingsSections';
interface DashboardSettingsDialogProps {
isOpen: boolean;
@@ -65,6 +42,7 @@ export function DashboardSettingsDialog({
}: DashboardSettingsDialogProps) {
const [localSettings, setLocalSettings] = useState<DashboardSettings>(settings);
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
todayIssueList: false,
entertainment: false,
welfare: false,
statusBoard: false,
@@ -192,8 +170,8 @@ export function DashboardSettingsDialog({
// 접대비 설정 변경
const handleEntertainmentChange = useCallback(
(
key: 'enabled' | 'limitType' | 'companyType',
value: boolean | EntertainmentLimitType | CompanyType
key: 'enabled' | 'limitType' | 'companyType' | 'highAmountThreshold',
value: boolean | EntertainmentLimitType | CompanyType | number
) => {
setLocalSettings((prev) => ({
...prev,
@@ -248,85 +226,6 @@ export function DashboardSettingsDialog({
onClose();
}, [settings, onClose]);
// 커스텀 스위치 (라이트 테마용)
const ToggleSwitch = ({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) => (
<button
type="button"
onClick={() => onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
checked ? 'bg-blue-500' : 'bg-gray-300'
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow-md transition-transform',
checked ? 'translate-x-6' : 'translate-x-1'
)}
/>
</button>
);
// 섹션 행 컴포넌트 (라이트 테마)
const SectionRow = ({
label,
checked,
onCheckedChange,
hasExpand,
isExpanded,
onToggleExpand,
children,
showGrip,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
hasExpand?: boolean;
isExpanded?: boolean;
onToggleExpand?: () => void;
children?: React.ReactNode;
showGrip?: boolean;
}) => (
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
<div
className={cn(
'flex items-center justify-between py-3 px-4 bg-gray-200',
children && isExpanded ? 'rounded-t-lg' : 'rounded-lg'
)}
>
<div className="flex items-center gap-2">
{showGrip && (
<GripVertical className="h-4 w-4 text-gray-400 cursor-grab flex-shrink-0" />
)}
{hasExpand && (
<CollapsibleTrigger asChild>
<button type="button" className="p-1 hover:bg-gray-300 rounded">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-500" />
) : (
<ChevronDown className="h-4 w-4 text-gray-500" />
)}
</button>
</CollapsibleTrigger>
)}
<span className="text-sm font-medium text-gray-800">{label}</span>
</div>
<ToggleSwitch checked={checked} onCheckedChange={onCheckedChange} />
</div>
{children && (
<CollapsibleContent className="px-4 py-3 space-y-3 bg-gray-50 rounded-b-lg">
{children}
</CollapsibleContent>
)}
</Collapsible>
);
// 섹션 렌더링 함수
const renderSection = (key: SectionKey): React.ReactNode => {
switch (key) {
@@ -336,8 +235,16 @@ export function DashboardSettingsDialog({
label={SECTION_LABELS.todayIssueList}
checked={localSettings.todayIssueList}
onCheckedChange={handleTodayIssueListToggle}
hasExpand
isExpanded={expandedSections.todayIssueList}
onToggleExpand={() => toggleSection('todayIssueList')}
showGrip
/>
>
<StatusBoardItemsList
items={localSettings.statusBoard?.items ?? localSettings.todayIssue.items}
onToggle={handleStatusBoardItemToggle}
/>
</SectionRow>
);
case 'dailyReport':
@@ -361,26 +268,10 @@ export function DashboardSettingsDialog({
onToggleExpand={() => toggleSection('statusBoard')}
showGrip
>
<div className="space-y-0">
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>).map(
(itemKey) => (
<div
key={itemKey}
className="flex items-center justify-between py-2.5 px-2"
>
<span className="text-sm text-gray-600">
{STATUS_BOARD_LABELS[itemKey]}
</span>
<ToggleSwitch
checked={(localSettings.statusBoard?.items ?? localSettings.todayIssue.items)[itemKey]}
onCheckedChange={(checked) =>
handleStatusBoardItemToggle(itemKey, checked)
}
/>
</div>
)
)}
</div>
<StatusBoardItemsList
items={localSettings.statusBoard?.items ?? localSettings.todayIssue.items}
onToggle={handleStatusBoardItemToggle}
/>
</SectionRow>
);
@@ -415,211 +306,12 @@ export function DashboardSettingsDialog({
onToggleExpand={() => toggleSection('entertainment')}
showGrip
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={localSettings.entertainment.limitType}
onValueChange={(value: EntertainmentLimitType) =>
handleEntertainmentChange('limitType', value)
}
>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="annual"></SelectItem>
<SelectItem value="quarterly"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={localSettings.entertainment.companyType}
onValueChange={(value: CompanyType) =>
handleEntertainmentChange('companyType', value)
}
>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="large"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="small"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 기업 구분 방법 설명 패널 */}
<Collapsible
open={expandedSections.companyTypeInfo}
onOpenChange={() => toggleSection('companyTypeInfo')}
>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center justify-between w-full py-2 px-3 text-sm text-gray-600 bg-gray-100 rounded hover:bg-gray-200"
>
<span> </span>
{expandedSections.companyTypeInfo ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 p-3 bg-white border border-gray-200 rounded text-xs space-y-4">
{/* ■ 중소기업 판단 기준표 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-800"></span>
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000</td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">·</td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
</tbody>
</table>
</div>
{/* ① 업종별 매출액 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> ( 3 )</span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,500 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">·</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400 </td></tr>
</tbody>
</table>
</div>
{/* ② 자산총액 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000 </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
</tbody>
</table>
</div>
{/* ③ 독립성 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> 30% </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> · </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
</tbody>
</table>
</div>
{/* ■ 판정 결과 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-800"></span>
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">3,600</td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,200</td>
</tr>
</tbody>
</table>
</div>
</CollapsibleContent>
</Collapsible>
</div>
<EntertainmentContent
entertainment={localSettings.entertainment}
onChange={handleEntertainmentChange}
companyTypeInfoExpanded={expandedSections.companyTypeInfo}
onToggleCompanyTypeInfo={() => toggleSection('companyTypeInfo')}
/>
</SectionRow>
);
@@ -634,87 +326,10 @@ export function DashboardSettingsDialog({
onToggleExpand={() => toggleSection('welfare')}
showGrip
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={localSettings.welfare.limitType}
onValueChange={(value: WelfareLimitType) =>
handleWelfareChange('limitType', value)
}
>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="annual"></SelectItem>
<SelectItem value="quarterly"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={localSettings.welfare.calculationType}
onValueChange={(value: WelfareCalculationType) =>
handleWelfareChange('calculationType', value)
}
>
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fixed"> </SelectItem>
<SelectItem value="ratio"> X </SelectItem>
</SelectContent>
</Select>
</div>
{localSettings.welfare.calculationType === 'fixed' ? (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> /</span>
<div className="flex items-center gap-1">
<CurrencyInput
value={localSettings.welfare.fixedAmountPerMonth}
onChange={(value) =>
handleWelfareChange(
'fixedAmountPerMonth',
value ?? 0
)
}
className="w-28 h-8"
/>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"></span>
<div className="flex items-center gap-1">
<NumberInput
step={0.1}
allowDecimal
value={localSettings.welfare.ratio}
onChange={(value) =>
handleWelfareChange('ratio', value ?? 0)
}
className="w-20 h-8 text-right"
/>
<span className="text-sm text-gray-500">%</span>
</div>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<div className="flex items-center gap-1">
<CurrencyInput
value={localSettings.welfare.annualTotal}
onChange={(value) =>
handleWelfareChange('annualTotal', value ?? 0)
}
className="w-32 h-8"
/>
</div>
</div>
</div>
<WelfareContent
welfare={localSettings.welfare}
onChange={handleWelfareChange}
/>
</SectionRow>
);

View File

@@ -0,0 +1,493 @@
'use client';
import { ChevronDown, ChevronUp, GripVertical } from 'lucide-react';
import { CurrencyInput } from '@/components/ui/currency-input';
import { NumberInput } from '@/components/ui/number-input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible';
import { cn } from '@/lib/utils';
import type {
TodayIssueSettings,
DashboardSettings,
EntertainmentLimitType,
CompanyType,
WelfareLimitType,
WelfareCalculationType,
} from '../types';
// ─── 현황판 항목 라벨 ──────────────────────────────
export const STATUS_BOARD_LABELS: Record<keyof TodayIssueSettings, string> = {
orders: '수주',
debtCollection: '채권 추심',
safetyStock: '안전 재고',
taxReport: '세금 신고',
newVendor: '신규 업체 등록',
annualLeave: '연차',
vehicle: '차량',
equipment: '장비',
purchase: '발주', // [2026-03-03] 비활성화 — 설정 모달에서 숨김 처리 (STATUS_BOARD_HIDDEN_SETTINGS)
approvalRequest: '결재 요청',
fundStatus: '자금 현황',
};
// ─── 커스텀 스위치 ──────────────────────────────────
export function ToggleSwitch({
checked,
onCheckedChange,
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
}) {
return (
<button
type="button"
onClick={() => onCheckedChange(!checked)}
className={cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
checked ? 'bg-blue-500' : 'bg-gray-300'
)}
>
<span
className={cn(
'inline-block h-4 w-4 transform rounded-full bg-white shadow-md transition-transform',
checked ? 'translate-x-6' : 'translate-x-1'
)}
/>
</button>
);
}
// ─── 섹션 행 (Collapsible 래퍼) ─────────────────────
export function SectionRow({
label,
checked,
onCheckedChange,
hasExpand,
isExpanded,
onToggleExpand,
children,
showGrip,
}: {
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
hasExpand?: boolean;
isExpanded?: boolean;
onToggleExpand?: () => void;
children?: React.ReactNode;
showGrip?: boolean;
}) {
return (
<Collapsible open={isExpanded} onOpenChange={onToggleExpand}>
<div
className={cn(
'flex items-center justify-between py-3 px-4 bg-gray-200',
children && isExpanded ? 'rounded-t-lg' : 'rounded-lg'
)}
>
<div className="flex items-center gap-2">
{showGrip && (
<GripVertical className="h-4 w-4 text-gray-400 cursor-grab flex-shrink-0" />
)}
{hasExpand && (
<CollapsibleTrigger asChild>
<button type="button" className="p-1 hover:bg-gray-300 rounded">
{isExpanded ? (
<ChevronUp className="h-4 w-4 text-gray-500" />
) : (
<ChevronDown className="h-4 w-4 text-gray-500" />
)}
</button>
</CollapsibleTrigger>
)}
<span className="text-sm font-medium text-gray-800">{label}</span>
</div>
<ToggleSwitch checked={checked} onCheckedChange={onCheckedChange} />
</div>
{children && (
<CollapsibleContent className="px-4 py-3 space-y-3 bg-gray-50 rounded-b-lg">
{children}
</CollapsibleContent>
)}
</Collapsible>
);
}
// [2026-03-03] 설정 모달에서 숨길 항목
// - purchase: 백엔드 path 오류 + 데이터 정합성 이슈 (API-SPEC N4 참조)
// - vehicle, equipment, fundStatus: 백엔드 API에서 미제공 (StatusBoard 응답에 없음)
const STATUS_BOARD_HIDDEN_SETTINGS = new Set<keyof TodayIssueSettings>([
'purchase', 'vehicle', 'equipment', 'fundStatus',
]);
// ─── 현황판 항목 토글 리스트 ────────────────────────
export function StatusBoardItemsList({
items,
onToggle,
}: {
items: TodayIssueSettings;
onToggle: (key: keyof TodayIssueSettings, checked: boolean) => void;
}) {
return (
<div className="space-y-0">
{(Object.keys(STATUS_BOARD_LABELS) as Array<keyof TodayIssueSettings>)
.filter((itemKey) => !STATUS_BOARD_HIDDEN_SETTINGS.has(itemKey))
.map((itemKey) => (
<div
key={itemKey}
className="flex items-center justify-between py-2.5 px-2"
>
<span className="text-sm text-gray-600">
{STATUS_BOARD_LABELS[itemKey]}
</span>
<ToggleSwitch
checked={items[itemKey]}
onCheckedChange={(checked) => onToggle(itemKey, checked)}
/>
</div>
)
)}
</div>
);
}
// ─── 기업 구분 방법 설명 패널 ───────────────────────
function CompanyTypeInfoPanel({
isExpanded,
onToggle,
}: {
isExpanded: boolean;
onToggle: () => void;
}) {
return (
<Collapsible open={isExpanded} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center justify-between w-full py-2 px-3 text-sm text-gray-600 bg-gray-100 rounded hover:bg-gray-200"
>
<span> </span>
{isExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2 p-3 bg-white border border-gray-200 rounded text-xs space-y-4">
{/* ■ 중소기업 판단 기준표 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-800"></span>
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000</td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">·</td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
</tbody>
</table>
</div>
{/* ① 업종별 매출액 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> ( 3 )</span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,500 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,000 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">600 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">·</td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400 </td></tr>
<tr><td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td><td className="border border-gray-200 px-2 py-1 text-center text-gray-600">400 </td></tr>
</tbody>
</table>
</div>
{/* ② 자산총액 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">5,000 </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
</tr>
</tbody>
</table>
</div>
{/* ③ 독립성 기준 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> 30% </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-gray-600"> · </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
</tr>
</tbody>
</table>
</div>
{/* ■ 판정 결과 */}
<div>
<div className="flex items-center gap-2 mb-2">
<span className="font-bold text-gray-800"></span>
<span className="text-sm font-medium text-gray-800"> </span>
</div>
<table className="w-full border-collapse text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"></th>
<th className="border border-gray-300 px-2 py-1 text-center text-gray-700"> </th>
</tr>
</thead>
<tbody>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">3,600</td>
</tr>
<tr>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"></td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600"> </td>
<td className="border border-gray-200 px-2 py-1 text-center text-gray-600">1,200</td>
</tr>
</tbody>
</table>
</div>
</CollapsibleContent>
</Collapsible>
);
}
// ─── 접대비 설정 콘텐츠 ─────────────────────────────
export function EntertainmentContent({
entertainment,
onChange,
companyTypeInfoExpanded,
onToggleCompanyTypeInfo,
}: {
entertainment: DashboardSettings['entertainment'];
onChange: (
key: 'limitType' | 'companyType' | 'highAmountThreshold',
value: EntertainmentLimitType | CompanyType | number,
) => void;
companyTypeInfoExpanded: boolean;
onToggleCompanyTypeInfo: () => void;
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={entertainment.limitType}
onValueChange={(value: EntertainmentLimitType) => onChange('limitType', value)}
>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="annual"></SelectItem>
<SelectItem value="quarterly"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={entertainment.companyType}
onValueChange={(value: CompanyType) => onChange('companyType', value)}
>
<SelectTrigger className="w-28 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="small"></SelectItem>
<SelectItem value="medium"></SelectItem>
<SelectItem value="large"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<div className="flex items-center gap-1">
<CurrencyInput
value={entertainment.highAmountThreshold}
onChange={(value) => onChange('highAmountThreshold', value ?? 0)}
className="w-28 h-8"
/>
</div>
</div>
<CompanyTypeInfoPanel
isExpanded={companyTypeInfoExpanded}
onToggle={onToggleCompanyTypeInfo}
/>
</div>
);
}
// ─── 복리후생비 설정 콘텐츠 ─────────────────────────
export function WelfareContent({
welfare,
onChange,
}: {
welfare: DashboardSettings['welfare'];
onChange: (
key: keyof DashboardSettings['welfare'],
value: WelfareLimitType | WelfareCalculationType | number,
) => void;
}) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={welfare.limitType}
onValueChange={(value: WelfareLimitType) => onChange('limitType', value)}
>
<SelectTrigger className="w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="annual"></SelectItem>
<SelectItem value="quarterly"></SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<Select
value={welfare.calculationType}
onValueChange={(value: WelfareCalculationType) => onChange('calculationType', value)}
>
<SelectTrigger className="w-40 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="fixed"> </SelectItem>
<SelectItem value="ratio"> X </SelectItem>
</SelectContent>
</Select>
</div>
{welfare.calculationType === 'fixed' ? (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> /</span>
<div className="flex items-center gap-1">
<CurrencyInput
value={welfare.fixedAmountPerMonth}
onChange={(value) => onChange('fixedAmountPerMonth', value ?? 0)}
className="w-28 h-8"
/>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"></span>
<div className="flex items-center gap-1">
<NumberInput
step={0.1}
allowDecimal
value={welfare.ratio}
onChange={(value) => onChange('ratio', value ?? 0)}
className="w-20 h-8 text-right"
/>
<span className="text-sm text-gray-500">%</span>
</div>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600"> </span>
<span className="text-sm font-medium text-gray-800">
{welfare.annualTotal.toLocaleString()}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-gray-600">1 </span>
<div className="flex items-center gap-1">
<CurrencyInput
value={welfare.singlePaymentThreshold}
onChange={(value) => onChange('singlePaymentThreshold', value ?? 0)}
className="w-32 h-8"
/>
</div>
</div>
</div>
);
}

View File

@@ -1,524 +1,10 @@
import type {
CEODashboardData,
SalesStatusData,
PurchaseStatusData,
DailyProductionData,
UnshippedData,
DailyAttendanceData,
} from './types';
import type { CEODashboardData } from './types';
/**
* CEO 대시보드 목데이터
* TODO: API 연동 시 이 파일을 API 호출로 대체
* Mock 데이터 제거 완료 — 빈 기본값만 유지
* dev 페이지에서 import하므로 파일 유지
*/
export const mockData: CEODashboardData = {
// TodayIssue: API 연동 완료 - 목업 데이터 제거됨
todayIssue: [],
todayIssueList: [],
dailyReport: {
date: '2026년 1월 5일 월요일',
cards: [
{ id: 'dr1', label: '일일일보', amount: 3050000000, path: '/ko/accounting/daily-report' },
{ id: 'dr2', label: '매출채권 잔액', amount: 3050000000, path: '/ko/accounting/receivables-status' },
{ id: 'dr3', label: '매입채무 잔액', amount: 3050000000 },
{ id: 'dr4', label: '운영자금 잔여', amount: 0, displayValue: '6.2개월' },
],
checkPoints: [
{
id: 'dr-cp1',
type: 'success',
message: '어제 3.5억원 출금했습니다. 최근 7일 평균 대비 2배 이상으로 점검이 필요합니다.',
highlights: [
{ text: '3.5억원 출금', color: 'red' },
{ text: '점검이 필요', color: 'red' },
],
},
{
id: 'dr-cp2',
type: 'success',
message: '어제 10.2억원이 입금되었습니다. 대한건설 선수금 입금이 주요 원인입니다.',
highlights: [
{ text: '10.2억원', color: 'green' },
{ text: '입금', color: 'green' },
{ text: '대한건설 선수금 입금', color: 'green' },
],
},
{
id: 'dr-cp3',
type: 'success',
message: '총 현금성 자산이 300.2억원입니다. 월 운영비용 대비 18개월분이 확보되어 안정적입니다.',
highlights: [
{ text: '18개월분', color: 'blue' },
{ text: '안정적', color: 'blue' },
],
},
],
},
monthlyExpense: {
cards: [
{ id: 'me1', label: '매입', amount: 3050000000, previousLabel: '전월 대비 +10.5%' },
{ id: 'me2', label: '카드', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
{ id: 'me3', label: '발행어음', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
{ id: 'me4', label: '총 예상 지출 합계', amount: 350000000, previousLabel: '전월 대비 +10.5%' },
],
checkPoints: [
{
id: 'me-cp1',
type: 'success',
message: '이번 달 예상 지출이 전월 대비 15% 증가했습니다. 매입 비용 증가가 주요 원인입니다.',
highlights: [
{ text: '전월 대비 15% 증가', color: 'red' },
],
},
{
id: 'me-cp2',
type: 'success',
message: '이번 달 예상 지출이 예산을 12% 초과했습니다. 비용 항목별 점검이 필요합니다.',
highlights: [
{ text: '예산을 12% 초과', color: 'red' },
],
},
{
id: 'me-cp3',
type: 'success',
message: '이번 달 예상 지출이 전월 대비 8% 감소했습니다. {계정과목명} 비용이 줄었습니다.',
highlights: [
{ text: '전월 대비 8% 감소', color: 'green' },
],
},
],
},
cardManagement: {
warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의',
cards: [
{ id: 'cm1', label: '카드', amount: 30123000, previousLabel: '미정리 5건 (가지급금 예정)' },
{ id: 'cm2', label: '가지급금', amount: 350000000, previousLabel: '전월 대비 +10.5%' },
{ id: 'cm3', label: '법인세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' },
{ id: 'cm4', label: '대표자 종합세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' },
],
checkPoints: [
{
id: 'cm-cp1',
type: 'success',
message: '법인카드 사용 총 850만원이 가지급금으로 전환되었습니다. 연 4.6% 인정이자가 발생합니다.',
highlights: [
{ text: '850만원', color: 'red' },
{ text: '가지급금', color: 'red' },
{ text: '연 4.6% 인정이자', color: 'red' },
],
},
{
id: 'cm-cp2',
type: 'success',
message: '현재 가지급금 3.5원 × 4.6% = 연 약 1,400만원의 인정이자가 발생 중입니다.',
highlights: [
{ text: '연 약 1,400만원의 인정이자', color: 'red' },
],
},
{
id: 'cm-cp3',
type: 'success',
message: '상품권/귀금속 등 접대비 불인정 항목 결제 감지. 가지급금 처리 예정입니다.',
highlights: [
{ text: '불인정 항목 결제 감지', color: 'red' },
],
},
{
id: 'cm-cp4',
type: 'success',
message: '주말 카드 사용 100만원 결제 감지. 업무관련성 소명이 어려울 수 있으니 기록을 남겨주세요.',
highlights: [
{ text: '주말 카드 사용 100만원 결제 감지', color: 'red' },
],
},
],
},
entertainment: {
cards: [
{ id: 'et1', label: '매출', amount: 30530000000 },
{ id: 'et2', label: '{1사분기} 접대비 총 한도', amount: 40123000 },
{ id: 'et3', label: '{1사분기} 접대비 잔여한도', amount: 30123000 },
{ id: 'et4', label: '{1사분기} 접대비 사용금액', amount: 10000000 },
],
checkPoints: [
{
id: 'et-cp1',
type: 'success',
message: '{1사분기} 접대비 사용 1,000만원 / 한도 4,012만원 (75%). 여유 있게 운영 중입니다.',
highlights: [
{ text: '1,000만원', color: 'green' },
{ text: '4,012만원 (75%)', color: 'green' },
],
},
{
id: 'et-cp2',
type: 'success',
message: '접대비 한도 85% 도달. 잔여 한도 600만원입니다. 사용 계획을 점검해 주세요.',
highlights: [
{ text: '잔여 한도 600만원', color: 'red' },
],
},
{
id: 'et-cp3',
type: 'error',
message: '접대비 한도 초과 320만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.',
highlights: [
{ text: '320만원 발생', color: 'red' },
],
},
{
id: 'et-cp4',
type: 'error',
message: '접대비 사용 중 3건(45만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.',
highlights: [
{ text: '3건(45만원)', color: 'red' },
{ text: '거래처 정보가 누락', color: 'red' },
],
},
],
},
welfare: {
cards: [
{ id: 'wf1', label: '당해년도 복리후생비 한도', amount: 30123000 },
{ id: 'wf2', label: '{1사분기} 복리후생비 총 한도', amount: 10123000 },
{ id: 'wf3', label: '{1사분기} 복리후생비 잔여한도', amount: 5123000 },
{ id: 'wf4', label: '{1사분기} 복리후생비 사용금액', amount: 5123000 },
],
checkPoints: [
{
id: 'wf-cp1',
type: 'success',
message: '1인당 월 복리후생비 20만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.',
highlights: [
{ text: '1인당 월 복리후생비 20만원', color: 'green' },
],
},
{
id: 'wf-cp2',
type: 'error',
message: '식대가 월 25만원으로 비과세 한도(20만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.',
highlights: [
{ text: '식대가 월 25만원으로', color: 'red' },
{ text: '초과', color: 'red' },
],
},
],
},
receivable: {
cards: [
{
id: 'rv1',
label: '누적 미수금',
amount: 30123000,
subItems: [
{ label: '매출', value: 60123000 },
{ label: '입금', value: 30000000 },
],
},
{
id: 'rv2',
label: '당월 미수금',
amount: 10123000,
subItems: [
{ label: '매출', value: 60123000 },
{ label: '입금', value: 30000000 },
],
},
{
id: 'rv3',
label: '회사명',
amount: 3123000,
subItems: [
{ label: '매출', value: 6123000 },
{ label: '입금', value: 3000000 },
],
},
{
id: 'rv4',
label: '회사명',
amount: 2123000,
subItems: [
{ label: '매출', value: 6123000 },
{ label: '입금', value: 3000000 },
],
},
],
checkPoints: [
{
id: 'rv-cp1',
type: 'success',
message: '90일 이상 장기 미수금 3건(2,500만원) 발생. 회수 조치가 필요합니다.',
highlights: [
{ text: '90일 이상 장기 미수금 3건(2,500만원) 발생', color: 'red' },
],
},
{
id: 'rv-cp2',
type: 'success',
message: '(주)대한전자 미수금 1,500만원으로 전체의 35%를 차지합니다. 리스크 분산이 필요합니다.',
highlights: [
{ text: '(주)대한전자 미수금 1,500만원으로 전체의 35%를', color: 'red' },
],
},
],
detailButtonPath: '/ko/accounting/receivables-status',
},
debtCollection: {
cards: [
{ id: 'dc1', label: '누적 악성채권', amount: 350000000, subLabel: '25건' },
{ id: 'dc2', label: '추심중', amount: 30123000, subLabel: '12건' },
{ id: 'dc3', label: '법적조치', amount: 3123000, subLabel: '3건' },
{ id: 'dc4', label: '회수완료', amount: 280000000, subLabel: '10건' },
],
checkPoints: [
{
id: 'dc-cp1',
type: 'success',
message: '(주)대한전자 건 지급명령 신청 완료. 법원 결정까지 약 2주 소요 예정입니다.',
highlights: [{ text: '(주)대한전자 건 지급명령 신청 완료.', color: 'red' }],
},
{
id: 'dc-cp2',
type: 'success',
message: '(주)삼성테크 건 회수 불가 판정. 대손 처리 검토가 필요합니다.',
highlights: [{ text: '(주)삼성테크 건 회수 불가 판정.', color: 'red' }],
},
],
detailButtonPath: '/ko/accounting/bad-debt-collection',
},
vat: {
cards: [
{ id: 'vat1', label: '매출세액', amount: 3050000000 },
{ id: 'vat2', label: '매입세액', amount: 2050000000 },
{ id: 'vat3', label: '예상 납부세액', amount: 110000000 },
{ id: 'vat4', label: '세금계산서 미발행', amount: 3, unit: '건' },
],
checkPoints: [
{
id: 'vat-cp1',
type: 'success',
message: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다. 설비투자에 따른 매입세액 증가가 주요 원인입니다.',
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다.', color: 'red' }],
},
{
id: 'vat-cp2',
type: 'success',
message: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다. 전기 대비 12.9% 증가했으며, 이는 매출 증가에 따른 정상적인 증가로 판단됩니다.',
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다.', color: 'red' }],
},
],
},
// ===== 신규 섹션 Mock 데이터 =====
salesStatus: {
cumulativeSales: 312300000,
achievementRate: 94.5,
yoyChange: 12.5,
monthlySales: 312300000,
monthlyTrend: [
{ month: '8월', amount: 250000000 },
{ month: '9월', amount: 280000000 },
{ month: '10월', amount: 310000000 },
{ month: '11월', amount: 290000000 },
{ month: '12월', amount: 320000000 },
{ month: '1월', amount: 300000000 },
{ month: '2월', amount: 312300000 },
],
clientSales: [
{ name: '대한건설', amount: 95000000 },
{ name: '삼성테크', amount: 78000000 },
{ name: '현대산업', amount: 62000000 },
{ name: 'LG전자', amount: 45000000 },
{ name: '기타', amount: 32300000 },
],
dailyItems: [
{ date: '2026-02-01', client: '대한건설', item: '스크린 외', amount: 25000000, status: '입금완료' },
{ date: '2026-02-03', client: '삼성테크', item: '슬루 외', amount: 18000000, status: '미입금' },
{ date: '2026-02-05', client: '현대산업', item: '절곡 외', amount: 32000000, status: '입금완료' },
{ date: '2026-02-07', client: 'LG전자', item: '스크린', amount: 15000000, status: '부분입금' },
{ date: '2026-02-10', client: '대한건설', item: '슬루', amount: 28000000, status: '입금완료' },
{ date: '2026-02-12', client: '삼성테크', item: '절곡', amount: 22000000, status: '미입금' },
{ date: '2026-02-15', client: '현대산업', item: '스크린 외', amount: 35000000, status: '입금완료' },
],
dailyTotal: 312300000,
},
purchaseStatus: {
cumulativePurchase: 312300000,
unpaidAmount: 312300000,
yoyChange: -12.5,
monthlyTrend: [
{ month: '8월', amount: 180000000 },
{ month: '9월', amount: 200000000 },
{ month: '10월', amount: 220000000 },
{ month: '11월', amount: 195000000 },
{ month: '12월', amount: 230000000 },
{ month: '1월', amount: 210000000 },
{ month: '2월', amount: 312300000 },
],
materialRatio: [
{ name: '원자재', value: 55, percentage: 55, color: '#3b82f6' },
{ name: '부자재', value: 35, percentage: 35, color: '#10b981' },
{ name: '소모품', value: 10, percentage: 10, color: '#f59e0b' },
],
dailyItems: [
{ date: '2026-02-01', supplier: '한국철강', item: '철판 외', amount: 45000000, status: '결제완료' },
{ date: '2026-02-03', supplier: '삼성소재', item: '알루미늄', amount: 28000000, status: '미결제' },
{ date: '2026-02-05', supplier: '현대자재', item: '볼트/너트', amount: 12000000, status: '결제완료' },
{ date: '2026-02-08', supplier: 'LG화학', item: '도료 외', amount: 18000000, status: '부분결제' },
{ date: '2026-02-10', supplier: '한국철강', item: '스테인리스', amount: 52000000, status: '미결제' },
{ date: '2026-02-13', supplier: '삼성소재', item: '구리판', amount: 35000000, status: '결제완료' },
],
dailyTotal: 312300000,
},
dailyProduction: {
date: '2026년 2월 23일 월요일',
processes: [
{
processName: '스크린',
totalWork: 10,
todo: 10,
inProgress: 10,
completed: 10,
urgent: 3,
subLine: 2,
regular: 5,
workerCount: 8,
workItems: [
{ id: 'sp1', orderNo: 'SO-2026-001', client: '대한건설', product: '스크린 A형', quantity: 50, status: '진행중' },
{ id: 'sp2', orderNo: 'SO-2026-002', client: '삼성테크', product: '스크린 B형', quantity: 30, status: '진행중' },
{ id: 'sp3', orderNo: 'SO-2026-003', client: '현대산업', product: '스크린 C형', quantity: 20, status: '대기' },
{ id: 'sp4', orderNo: 'SO-2026-004', client: 'LG전자', product: '스크린 D형', quantity: 40, status: '대기' },
{ id: 'sp5', orderNo: 'SO-2026-005', client: '대한건설', product: '스크린 E형', quantity: 25, status: '완료' },
],
workers: [
{ name: '김철수', assigned: 5, completed: 3, rate: 60 },
{ name: '이영희', assigned: 4, completed: 4, rate: 100 },
{ name: '박민수', assigned: 3, completed: 2, rate: 67 },
{ name: '정수진', assigned: 3, completed: 1, rate: 33 },
],
},
{
processName: '슬랫',
totalWork: 10,
todo: 10,
inProgress: 10,
completed: 10,
urgent: 2,
subLine: 3,
regular: 5,
workerCount: 6,
workItems: [
{ id: 'sl1', orderNo: 'SO-2026-010', client: '대한건설', product: '슬루 A형', quantity: 40, status: '진행중' },
{ id: 'sl2', orderNo: 'SO-2026-011', client: '삼성테크', product: '슬루 B형', quantity: 25, status: '진행중' },
{ id: 'sl3', orderNo: 'SO-2026-012', client: '현대산업', product: '슬루 C형', quantity: 35, status: '대기' },
],
workers: [
{ name: '최동훈', assigned: 4, completed: 3, rate: 75 },
{ name: '강미영', assigned: 3, completed: 2, rate: 67 },
{ name: '윤상호', assigned: 3, completed: 3, rate: 100 },
],
},
{
processName: '절곡',
totalWork: 10,
todo: 10,
inProgress: 10,
completed: 10,
urgent: 1,
subLine: 2,
regular: 7,
workerCount: 5,
workItems: [
{ id: 'jg1', orderNo: 'SO-2026-020', client: '현대산업', product: '절곡 A형', quantity: 60, status: '진행중' },
{ id: 'jg2', orderNo: 'SO-2026-021', client: 'LG전자', product: '절곡 B형', quantity: 45, status: '대기' },
{ id: 'jg3', orderNo: 'SO-2026-022', client: '대한건설', product: '절곡 C형', quantity: 30, status: '완료' },
],
workers: [
{ name: '한지원', assigned: 4, completed: 4, rate: 100 },
{ name: '서준혁', assigned: 3, completed: 2, rate: 67 },
],
},
],
shipment: {
expectedAmount: 150000000,
expectedCount: 12,
actualAmount: 120000000,
actualCount: 9,
},
},
unshipped: {
items: [
{ id: 'us1', portNo: 'P-2026-001', siteName: '강남 현장', orderClient: '대한건설', dueDate: '2026-02-25', daysLeft: 2 },
{ id: 'us2', portNo: 'P-2026-002', siteName: '서초 현장', orderClient: '삼성테크', dueDate: '2026-02-26', daysLeft: 3 },
{ id: 'us3', portNo: 'P-2026-003', siteName: '판교 현장', orderClient: '현대산업', dueDate: '2026-02-27', daysLeft: 4 },
{ id: 'us4', portNo: 'P-2026-004', siteName: '송도 현장', orderClient: 'LG전자', dueDate: '2026-02-28', daysLeft: 5 },
{ id: 'us5', portNo: 'P-2026-005', siteName: '마포 현장', orderClient: '대한건설', dueDate: '2026-03-01', daysLeft: 6 },
{ id: 'us6', portNo: 'P-2026-006', siteName: '영등포 현장', orderClient: '삼성테크', dueDate: '2026-03-03', daysLeft: 8 },
{ id: 'us7', portNo: 'P-2026-007', siteName: '용산 현장', orderClient: '현대산업', dueDate: '2026-03-05', daysLeft: 10 },
],
},
constructionData: {
thisMonth: 15,
completed: 15,
items: [
{ id: 'cs1', siteName: '강남 현장', client: '대한건설', startDate: '2026-02-01', endDate: '2026-02-28', progress: 85, status: '진행중' },
{ id: 'cs2', siteName: '서초 현장', client: '삼성테크', startDate: '2026-02-05', endDate: '2026-03-05', progress: 60, status: '진행중' },
{ id: 'cs3', siteName: '판교 현장', client: '현대산업', startDate: '2026-02-10', endDate: '2026-03-10', progress: 40, status: '진행중' },
{ id: 'cs4', siteName: '송도 현장', client: 'LG전자', startDate: '2026-03-01', endDate: '2026-03-30', progress: 0, status: '예정' },
{ id: 'cs5', siteName: '마포 현장', client: '대한건설', startDate: '2026-01-15', endDate: '2026-02-15', progress: 100, status: '완료' },
],
},
dailyAttendance: {
present: 10,
onLeave: 10,
late: 10,
absent: 10,
employees: [
{ id: 'att1', department: '생산부', position: '과장', name: '김철수', status: '출근' },
{ id: 'att2', department: '영업부', position: '대리', name: '이영희', status: '출근' },
{ id: 'att3', department: '관리부', position: '사원', name: '박민수', status: '휴가' },
{ id: 'att4', department: '생산부', position: '부장', name: '정수진', status: '지각' },
{ id: 'att5', department: '영업부', position: '과장', name: '최동훈', status: '출근' },
{ id: 'att6', department: '관리부', position: '대리', name: '강미영', status: '결근' },
{ id: 'att7', department: '생산부', position: '사원', name: '윤상호', status: '출근' },
],
},
calendarSchedules: [
{
id: 'sch1',
title: '제목',
startDate: '2026-01-01',
endDate: '2026-01-04',
startTime: '09:00',
endTime: '12:00',
type: 'schedule',
department: '부서명',
},
{
id: 'sch2',
title: '제목',
startDate: '2026-01-06',
endDate: '2026-01-06',
type: 'schedule',
personName: '홍길동',
},
{
id: 'sch3',
title: '제목',
startDate: '2026-01-06',
endDate: '2026-01-06',
startTime: '09:00',
endTime: '12:00',
type: 'order',
department: '부서명',
},
{
id: 'sch4',
title: '제목',
startDate: '2026-01-06',
endDate: '2026-01-06',
startTime: '12:35',
type: 'construction',
personName: '홍길동',
},
],
};
};

View File

@@ -162,16 +162,6 @@ export function transformCm1ModalConfig(
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
@@ -185,77 +175,104 @@ export function transformCm1ModalConfig(
// cm2: 가지급금 상세 모달 변환기
// ============================================
/** 카테고리 키 → 한글 라벨 매핑
* - category_breakdown 키: 영문 (card, congratulatory, ...)
* - loans[].category: 한글 (카드, 경조사, ...) — 백엔드 category_label accessor
* 양쪽 모두 대응
*/
const CATEGORY_LABELS: Record<string, string> = {
// 영문 키 (category_breakdown용)
card: '카드',
congratulatory: '경조사',
gift_certificate: '상품권',
entertainment: '접대비',
// 한글 값 (loans[].category가 이미 한글인 경우 — 그대로 통과)
'카드': '카드',
'경조사': '경조사',
'상품권': '상품권',
'접대비': '접대비',
};
/**
* 가지급금 대시보드 API 응답을 cm2 모달 설정으로 변환
*/
export function transformCm2ModalConfig(
data: LoanDashboardApiResponse
): DetailModalConfig {
const { summary, items = [] } = data;
const { summary, category_breakdown, loans = [] } = data;
// 테이블 데이터 매핑
const tableData = (items || []).map((item) => ({
// 테이블 데이터 매핑 (백엔드 필드명 기준, 영문 키 → 한글 변환)
const tableData = (loans || []).map((item) => ({
date: item.loan_date,
target: item.user_name,
category: '-', // API에서 별도 필드 없음
classification: CATEGORY_LABELS[item.category] || item.category || '카드',
category: item.status_label || '-',
amount: item.amount,
status: item.status_label || item.status,
content: item.description,
response: item.content,
}));
// 대상 필터 옵션 동적 생성
const uniqueTargets = [...new Set((items || []).map((item) => item.user_name))];
const targetFilterOptions = [
// 분류 필터 옵션 동적 생성
const uniqueClassifications = [...new Set(tableData.map((item) => item.classification))];
const classificationFilterOptions = [
{ value: 'all', label: '전체' },
...uniqueTargets.map((target) => ({
value: target,
label: target,
...uniqueClassifications.map((cls) => ({
value: cls,
label: cls,
})),
];
// reviewCards: category_breakdown에서 4개 카테고리 카드 생성
const reviewCards = category_breakdown
? {
title: '가지급금 검토 필요',
cards: Object.entries(category_breakdown).map(([key, breakdown]) => ({
label: CATEGORY_LABELS[key] || key,
amount: breakdown.outstanding_amount,
subLabel: breakdown.unverified_count > 0
? `미증빙 ${breakdown.unverified_count}`
: `${breakdown.total_count}`,
})),
}
: undefined;
return {
title: '가지급금 상세',
dateFilter: {
enabled: true,
defaultPreset: '당월',
showSearch: true,
},
summaryCards: [
{ label: '가지급금', value: formatKoreanCurrency(summary.total_outstanding) },
{ label: '인정이자 4.6%', value: summary.recognized_interest, unit: '원' },
{ label: '미설정', value: `${summary.pending_count ?? 0}` },
{ label: '가지급금 합계', value: formatKoreanCurrency(summary.total_outstanding) },
{ label: '인정비율 4.6%', value: summary.recognized_interest, unit: '원' },
{ label: '건수', value: `${summary.outstanding_count ?? 0}` },
],
reviewCards,
table: {
title: '가지급금 관련 내역',
title: '가지급금 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '발생일', align: 'center' },
{ key: 'target', label: '대상', align: 'center' },
{ key: 'date', label: '발생일', align: 'center' },
{ key: 'classification', label: '분류', align: 'center' },
{ key: 'category', label: '구분', align: 'center' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'status', label: '상태', align: 'center', highlightValue: '미설정' },
{ key: 'content', label: '내용', align: 'left' },
{ key: 'response', label: '대응', align: 'left' },
],
data: tableData,
filters: [
{
key: 'target',
options: targetFilterOptions,
defaultValue: 'all',
},
{
key: 'category',
options: [
{ value: 'all', label: '전체' },
{ value: '카드명', label: '카드명' },
{ value: '계좌명', label: '계좌명' },
],
key: 'classification',
options: classificationFilterOptions,
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'all', label: '정렬' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
{ value: 'latest', label: '최신순' },
],
defaultValue: 'latest',
defaultValue: 'all',
},
],
showTotal: true,

View File

@@ -32,7 +32,7 @@ export interface CardManagementModalData {
/**
* API 데이터를 사용하여 모달 설정을 동적으로 생성
* 데이터가 없는 경우 fallback 설정 사용
* 데이터가 없는 경우 null 반환 (mock fallback 제거)
*/
export function getCardManagementModalConfigWithData(
cardId: string,
@@ -40,299 +40,26 @@ export function getCardManagementModalConfigWithData(
): DetailModalConfig | null {
switch (cardId) {
case 'cm1':
if (data.cm1Data) {
return transformCm1ModalConfig(data.cm1Data);
}
return getCardManagementModalConfig(cardId);
return data.cm1Data ? transformCm1ModalConfig(data.cm1Data) : null;
case 'cm2':
if (data.cm2Data) {
return transformCm2ModalConfig(data.cm2Data);
}
return getCardManagementModalConfig(cardId);
return data.cm2Data ? transformCm2ModalConfig(data.cm2Data) : null;
case 'cm3':
if (data.cm3Data) {
return transformCm3ModalConfig(data.cm3Data);
}
return getCardManagementModalConfig(cardId);
return data.cm3Data ? transformCm3ModalConfig(data.cm3Data) : null;
case 'cm4':
if (data.cm4Data) {
return transformCm4ModalConfig(data.cm4Data);
}
return getCardManagementModalConfig(cardId);
return data.cm4Data ? transformCm4ModalConfig(data.cm4Data) : null;
default:
return null;
}
}
// ============================================
// Fallback 모달 설정 (API 데이터 없을 때 사용)
// ============================================
/**
* Fallback: 정적 목업 데이터 기반 모달 설정
* API 데이터가 없을 때 사용
* Fallback 모달 설정 (mock 제거 완료 — null 반환)
* API 데이터가 없을 때 모달을 열지 않음
*/
export function getCardManagementModalConfig(cardId: string): DetailModalConfig | null {
const configs: Record<string, DetailModalConfig> = {
cm1: {
title: '카드 사용 상세',
summaryCards: [
{ label: '당월 카드 사용', value: 30123000, unit: '원' },
{ label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true },
{ label: '미정리 건수', value: '5건' },
],
barChart: {
title: '월별 카드 사용 추이',
data: [
{ name: '7월', value: 28000000 },
{ name: '8월', value: 32000000 },
{ name: '9월', value: 27000000 },
{ name: '10월', value: 35000000 },
{ name: '11월', value: 29000000 },
{ name: '12월', value: 30123000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '사용자별 카드 사용 비율',
data: [
{ name: '대표이사', value: 15000000, percentage: 50, color: '#60A5FA' },
{ name: '경영지원팀', value: 9000000, percentage: 30, color: '#34D399' },
{ name: '영업팀', value: 6123000, percentage: 20, color: '#FBBF24' },
],
},
table: {
title: '카드 사용 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'cardName', label: '카드명', align: 'left' },
{ key: 'user', label: '사용자', align: 'center' },
{ key: 'date', label: '사용일시', align: 'center', format: 'date' },
{ key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' },
],
data: [
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-05 18:30', store: '스타벅스 강남점', amount: 45000, usageType: '복리후생비' },
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-04 12:15', store: '한식당', amount: 350000, usageType: '접대비' },
{ cardName: '법인카드2', user: '경영지원팀', date: '2026-01-03 14:20', store: '오피스디포', amount: 125000, usageType: '소모품비' },
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-02 19:45', store: '골프장', amount: 850000, usageType: '미설정' },
{ cardName: '법인카드3', user: '영업팀', date: '2026-01-02 11:30', store: 'GS칼텍스', amount: 80000, usageType: '교통비' },
{ cardName: '법인카드2', user: '경영지원팀', date: '2026-01-01 16:00', store: '이마트', amount: 230000, usageType: '미설정' },
{ cardName: '법인카드1', user: '대표이사', date: '2025-12-30 20:30', store: '백화점', amount: 1500000, usageType: '미설정' },
{ cardName: '법인카드3', user: '영업팀', date: '2025-12-29 09:15', store: '커피빈', amount: 32000, usageType: '복리후생비' },
{ cardName: '법인카드2', user: '경영지원팀', date: '2025-12-28 13:45', store: '문구점', amount: 55000, usageType: '소모품비' },
{ cardName: '법인카드1', user: '대표이사', date: '2025-12-27 21:00', store: '호텔', amount: 450000, usageType: '미설정' },
],
filters: [
{
key: 'user',
options: [
{ value: 'all', label: '전체' },
{ value: '대표이사', label: '대표이사' },
{ value: '경영지원팀', label: '경영지원팀' },
{ value: '영업팀', label: '영업팀' },
],
defaultValue: 'all',
},
{
key: 'usageType',
options: [
{ value: 'all', label: '전체' },
{ value: '미설정', label: '미설정' },
{ value: '복리후생비', label: '복리후생비' },
{ value: '접대비', label: '접대비' },
{ value: '소모품비', label: '소모품비' },
{ value: '교통비', label: '교통비' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 30123000,
totalColumnKey: 'amount',
},
},
cm2: {
title: '가지급금 상세',
summaryCards: [
{ label: '가지급금', value: '4.5억원' },
{ label: '인정이자 4.6%', value: 6000000, unit: '원' },
{ label: '미설정', value: '10건' },
],
table: {
title: '가지급금 관련 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '발생일시', align: 'center' },
{ key: 'target', label: '대상', align: 'center' },
{ key: 'category', label: '구분', align: 'center' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'status', label: '상태', align: 'center', highlightValue: '미설정' },
{ key: 'content', label: '내용', align: 'left' },
],
data: [
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '미설정', content: '미설정' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접비(미정리)', content: '접대비 불인정' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미설정', content: '접대비 불인정' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미설정', content: '미설정' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '-', amount: 1000000, status: '미설정', content: '미설정' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접대비', content: '접대비 불인정' },
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '-', content: '복리후생비, 주말/심야 카드 사용' },
],
filters: [
{
key: 'target',
options: [
{ value: 'all', label: '전체' },
{ value: '홍길동', label: '홍길동' },
],
defaultValue: 'all',
},
{
key: 'category',
options: [
{ value: 'all', label: '전체' },
{ value: '카드명', label: '카드명' },
{ value: '계좌명', label: '계좌명' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 111000000,
totalColumnKey: 'amount',
},
},
cm3: {
title: '법인세 예상 가중 상세',
summaryCards: [
{ label: '법인세 예상 증가', value: 3123000, unit: '원' },
{ label: '인정 이자', value: 6000000, unit: '원' },
{ label: '가지급금', value: '4.5억원' },
{ label: '인정이자', value: 6000000, unit: '원' },
],
comparisonSection: {
leftBox: {
title: '없을때 법인세',
items: [
{ label: '과세표준', value: '3억원' },
{ label: '법인세', value: 50970000, unit: '원' },
],
borderColor: 'orange',
},
rightBox: {
title: '있을때 법인세',
items: [
{ label: '과세표준', value: '3.06억원' },
{ label: '법인세', value: 54093000, unit: '원' },
],
borderColor: 'blue',
},
vsLabel: '법인세 예상 증가',
vsValue: 3123000,
vsSubLabel: '법인 세율 -12.5%',
},
referenceTable: {
title: '법인세 과세표준 (2024년 기준)',
columns: [
{ key: 'bracket', label: '과세표준', align: 'left' },
{ key: 'rate', label: '세율', align: 'center' },
{ key: 'formula', label: '계산식', align: 'left' },
],
data: [
{ bracket: '2억원 이하', rate: '9%', formula: '과세표준 × 9%' },
{ bracket: '2억원 초과 ~ 200억원 이하', rate: '19%', formula: '1,800만원 + (2억원 초과분 × 19%)' },
{ bracket: '200억원 초과 ~ 3,000억원 이하', rate: '21%', formula: '37.62억원 + (200억원 초과분 × 21%)' },
{ bracket: '3,000억원 초과', rate: '24%', formula: '625.62억원 + (3,000억원 초과분 × 24%)' },
],
},
},
cm4: {
title: '대표자 종합소득세 예상 가중 상세',
summaryCards: [
{ label: '대표자 종합세 예상 가중', value: 3123000, unit: '원' },
{ label: '추가 세금', value: '+12.5%', isComparison: true, isPositive: false },
{ label: '가지급금', value: '4.5억원' },
{ label: '인정이자 4.6%', value: 6000000, unit: '원' },
],
comparisonSection: {
leftBox: {
title: '가지급금 인정이자가 반영된 종합소득세',
items: [
{ label: '현재 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
{ label: '현재 적용 세율', value: '19%' },
{ label: '현재 예상 세액', value: 10000000, unit: '원' },
],
borderColor: 'orange',
},
rightBox: {
title: '가지급금 인정이자가 정리된 종합소득세',
items: [
{ label: '가지급금 정리 시 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
{ label: '가지급금 정리 시 적용 세율', value: '19%' },
{ label: '가지급금 정리 시 예상 세액', value: 10000000, unit: '원' },
],
borderColor: 'blue',
},
vsLabel: '종합소득세 예상 절감',
vsValue: 3123000,
vsSubLabel: '감소 세금 -12.5%',
vsBreakdown: [
{ label: '종합소득세', value: -2000000, unit: '원' },
{ label: '지방소득세', value: -200000, unit: '원' },
{ label: '4대 보험', value: -1000000, unit: '원' },
],
},
referenceTable: {
title: '종합소득세 과세표준 (2024년 기준)',
columns: [
{ key: 'bracket', label: '과세표준', align: 'left' },
{ key: 'rate', label: '세율', align: 'center' },
{ key: 'deduction', label: '누진공제', align: 'right' },
{ key: 'formula', label: '계산식', align: 'left' },
],
data: [
{ bracket: '1,400만원 이하', rate: '6%', deduction: '-', formula: '과세표준 × 6%' },
{ bracket: '1,400만원 초과 ~ 5,000만원 이하', rate: '15%', deduction: '126만원', formula: '과세표준 × 15% - 126만원' },
{ bracket: '5,000만원 초과 ~ 8,800만원 이하', rate: '24%', deduction: '576만원', formula: '과세표준 × 24% - 576만원' },
{ bracket: '8,800만원 초과 ~ 1.5억원 이하', rate: '35%', deduction: '1,544만원', formula: '과세표준 × 35% - 1,544만원' },
{ bracket: '1.5억원 초과 ~ 3억원 이하', rate: '38%', deduction: '1,994만원', formula: '과세표준 × 38% - 1,994만원' },
{ bracket: '3억원 초과 ~ 5억원 이하', rate: '40%', deduction: '2,594만원', formula: '과세표준 × 40% - 2,594만원' },
{ bracket: '5억원 초과 ~ 10억원 이하', rate: '42%', deduction: '3,594만원', formula: '과세표준 × 42% - 3,594만원' },
{ bracket: '10억원 초과', rate: '45%', deduction: '6,594만원', formula: '과세표준 × 45% - 6,594만원' },
],
},
},
};
return configs[cardId] || null;
export function getCardManagementModalConfig(_cardId: string): DetailModalConfig | null {
return null;
}

View File

@@ -1,231 +1,10 @@
import type { DetailModalConfig } from '../types';
/**
* 접대비 상세 공통 모달 config (et2, et3, et4 공통)
*/
const entertainmentDetailConfig: DetailModalConfig = {
title: '접대비 상세',
summaryCards: [
// 첫 번째 줄: 당해년도
{ label: '당해년도 접대비 총한도', value: 3123000, unit: '원' },
{ label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' },
{ label: '당해년도 접대비 사용금액', value: 6000000, unit: '원' },
{ label: '당해년도 접대비 사용잔액', value: 0, unit: '원' },
// 두 번째 줄: 분기별
{ label: '1사분기 접대비 총한도', value: 3123000, unit: '원' },
{ label: '1사분기 접대비 잔여한도', value: 6000000, unit: '원' },
{ label: '1사분기 접대비 사용금액', value: 6000000, unit: '원' },
{ label: '1사분기 접대비 초과금액', value: 6000000, unit: '원' },
],
barChart: {
title: '월별 접대비 사용 추이',
data: [
{ name: '1월', value: 3500000 },
{ name: '2월', value: 4200000 },
{ name: '3월', value: 2300000 },
{ name: '4월', value: 3800000 },
{ name: '5월', value: 4500000 },
{ name: '6월', value: 3200000 },
{ name: '7월', value: 2800000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '사용자별 접대비 사용 비율',
data: [
{ name: '홍길동', value: 15000000, percentage: 53, color: '#60A5FA' },
{ name: '김철수', value: 10000000, percentage: 31, color: '#34D399' },
{ name: '이영희', value: 10000000, percentage: 10, color: '#FBBF24' },
{ name: '기타', value: 2000000, percentage: 6, color: '#F87171' },
],
},
table: {
title: '월별 접대비 사용 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'cardName', label: '카드명', align: 'left' },
{ key: 'user', label: '사용자', align: 'center' },
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
{ key: 'transDate', label: '거래일시', align: 'center', format: 'date' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'purpose', label: '사용용도', align: 'left' },
],
data: [
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
],
filters: [
{
key: 'user',
options: [
{ value: 'all', label: '전체' },
{ value: '홍길동', label: '홍길동' },
{ value: '김철수', label: '김철수' },
{ value: '이영희', label: '이영희' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 11000000,
totalColumnKey: 'amount',
},
// 접대비 손금한도 계산 - 기본한도 / 수입금액별 추가한도
referenceTables: [
{
title: '접대비 손금한도 계산 - 기본한도',
columns: [
{ key: 'type', label: '구분', align: 'left' },
{ key: 'limit', label: '기본한도', align: 'right' },
],
data: [
{ type: '일반법인', limit: '3,600만원 (연 1,200만원)' },
{ type: '중소기업', limit: '5,400만원 (연 3,600만원)' },
],
},
{
title: '수입금액별 추가한도',
columns: [
{ key: 'range', label: '수입금액', align: 'left' },
{ key: 'rate', label: '적용률', align: 'center' },
],
data: [
{ range: '100억원 이하', rate: '0.3%' },
{ range: '100억원 초과 ~ 500억원 이하', rate: '0.2%' },
{ range: '500억원 초과', rate: '0.03%' },
],
},
],
// 접대비 계산
calculationCards: {
title: '접대비 계산',
cards: [
{ label: '기본한도', value: 36000000 },
{ label: '추가한도', value: 91170000, operator: '+' },
{ label: '접대비 손금한도', value: 127170000, operator: '=' },
],
},
// 접대비 현황 (분기별)
quarterlyTable: {
title: '접대비 현황',
rows: [
{ label: '접대비 한도', q1: 31792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 127170000 },
{ label: '접대비 사용', q1: 10000000, q2: 0, q3: 0, q4: 0, total: 10000000 },
{ label: '접대비 잔여', q1: 21792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 117170000 },
],
},
};
/**
* 접대비 현황 모달 설정
* et_sales: 당해 매출 상세
* et_limit, et_remaining, et_used: 접대비 상세 (공통)
* API 연동 완료 — useEntertainmentDetail hook이 실제 데이터 반환
* 이 함수는 하위 호환용으로 유지하되 null 반환
*/
export function getEntertainmentModalConfig(cardId: string): DetailModalConfig | null {
const configs: Record<string, DetailModalConfig> = {
et_sales: {
title: '당해 매출 상세',
summaryCards: [
{ label: '당해년도 매출', value: 600000000, unit: '원' },
{ label: '전년 대비', value: '-12.5%', isComparison: true, isPositive: false },
{ label: '당월 매출', value: 6000000, unit: '원' },
],
barChart: {
title: '월별 매출 추이',
data: [
{ name: '1월', value: 85000000 },
{ name: '2월', value: 92000000 },
{ name: '3월', value: 78000000 },
{ name: '4월', value: 95000000 },
{ name: '5월', value: 88000000 },
{ name: '6월', value: 102000000 },
{ name: '7월', value: 60000000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
horizontalBarChart: {
title: '당해년도 거래처별 매출',
data: [
{ name: '(주)세우', value: 120000000 },
{ name: '대한건설', value: 95000000 },
{ name: '삼성테크', value: 78000000 },
{ name: '현대상사', value: 65000000 },
{ name: '기타', value: 42000000 },
],
color: '#60A5FA',
},
table: {
title: '일별 매출 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '매출일', align: 'center', format: 'date' },
{ key: 'vendor', label: '거래처', align: 'left' },
{ key: 'amount', label: '매출금액', align: 'right', format: 'currency' },
{ key: 'type', label: '매출유형', align: 'center', highlightValue: '미설정' },
],
data: [
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부품 매출' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '공사 매출' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
],
filters: [
{
key: 'type',
options: [
{ value: 'all', label: '전체' },
{ value: '상품 매출', label: '상품 매출' },
{ value: '부품 매출', label: '부품 매출' },
{ value: '공사 매출', label: '공사 매출' },
{ value: '임대 수익', label: '임대 수익' },
{ value: '기타 매출', label: '기타 매출' },
{ value: '미설정', label: '미설정' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 111000000,
totalColumnKey: 'amount',
},
},
// et_limit, et_remaining, et_used는 모두 동일한 접대비 상세 모달
et_limit: entertainmentDetailConfig,
et_remaining: entertainmentDetailConfig,
et_used: entertainmentDetailConfig,
};
return configs[cardId] || null;
export function getEntertainmentModalConfig(_cardId: string): DetailModalConfig | null {
return null;
}

View File

@@ -2,316 +2,9 @@ import type { DetailModalConfig } from '../types';
/**
* 당월 예상 지출 모달 설정
* API 연동 완료 — useMonthlyExpenseDetail hook이 실제 데이터 반환
* 이 함수는 하위 호환용으로 유지하되 null 반환
*/
export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null {
const configs: Record<string, DetailModalConfig> = {
me1: {
title: '당월 매입 상세',
summaryCards: [
{ label: '당월 매입', value: 3123000, unit: '원' },
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
],
barChart: {
title: '월별 매입 추이',
data: [
{ name: '1월', value: 45000000 },
{ name: '2월', value: 52000000 },
{ name: '3월', value: 48000000 },
{ name: '4월', value: 61000000 },
{ name: '5월', value: 55000000 },
{ name: '6월', value: 58000000 },
{ name: '7월', value: 50000000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '자재 유형별 구매 비율',
data: [
{ name: '원자재', value: 55000000, percentage: 55, color: '#60A5FA' },
{ name: '부자재', value: 35000000, percentage: 35, color: '#34D399' },
{ name: '포장재', value: 10000000, percentage: 10, color: '#FBBF24' },
],
},
table: {
title: '일별 매입 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'date', label: '매입일', align: 'center', format: 'date' },
{ key: 'vendor', label: '거래처', align: 'left' },
{ key: 'amount', label: '매입금액', align: 'right', format: 'currency' },
{ key: 'type', label: '매입유형', align: 'center' },
],
data: [
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' },
],
filters: [
{
key: 'type',
options: [
{ value: 'all', label: '전체' },
{ value: '원재료매입', label: '원재료매입' },
{ value: '부재료매입', label: '부재료매입' },
{ value: '미설정', label: '미설정' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '오래된순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 111000000,
totalColumnKey: 'amount',
},
},
me2: {
title: '당월 카드 상세',
summaryCards: [
{ label: '당월 카드 사용', value: 6000000, unit: '원' },
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
{ label: '이용건', value: '10건' },
],
barChart: {
title: '월별 카드 사용 추이',
data: [
{ name: '1월', value: 4500000 },
{ name: '2월', value: 5200000 },
{ name: '3월', value: 4800000 },
{ name: '4월', value: 6100000 },
{ name: '5월', value: 5500000 },
{ name: '6월', value: 5800000 },
{ name: '7월', value: 6000000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '사용자별 카드 사용 비율',
data: [
{ name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' },
{ name: '김길동', value: 35000000, percentage: 35, color: '#34D399' },
{ name: '이길동', value: 10000000, percentage: 10, color: '#FBBF24' },
],
},
table: {
title: '일별 카드 사용 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'cardName', label: '카드명', align: 'left' },
{ key: 'user', label: '사용자', align: 'center' },
{ key: 'date', label: '사용일시', align: 'center', format: 'date' },
{ key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' },
],
data: [
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-08 11:15', store: '가맹점명', amount: 1000000, usageType: '미설정' },
{ cardName: '카드명', user: '김길동', date: '2025-12-07 16:40', store: '가맹점명', amount: 5000000, usageType: '교통비' },
{ cardName: '카드명', user: '이길동', date: '2025-12-06 10:30', store: '가맹점명', amount: 1000000, usageType: '소모품비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-05 13:25', store: '스타벅스', amount: 45000, usageType: '복리후생비' },
{ cardName: '카드명', user: '김길동', date: '2025-12-04 19:50', store: '주유소', amount: 80000, usageType: '교통비' },
{ cardName: '카드명', user: '이길동', date: '2025-12-03 08:10', store: '편의점', amount: 15000, usageType: '미설정' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-02 12:00', store: '식당', amount: 250000, usageType: '접대비' },
{ cardName: '카드명', user: '김길동', date: '2025-12-01 15:35', store: '문구점', amount: 35000, usageType: '소모품비' },
{ cardName: '카드명', user: '홍길동', date: '2025-11-30 17:20', store: '호텔', amount: 350000, usageType: '미설정' },
{ cardName: '카드명', user: '이길동', date: '2025-11-29 09:00', store: '택시', amount: 25000, usageType: '교통비' },
{ cardName: '카드명', user: '김길동', date: '2025-11-28 14:15', store: '커피숍', amount: 32000, usageType: '복리후생비' },
{ cardName: '카드명', user: '홍길동', date: '2025-11-27 11:45', store: '마트', amount: 180000, usageType: '소모품비' },
{ cardName: '카드명', user: '이길동', date: '2025-11-26 16:30', store: '서점', amount: 45000, usageType: '미설정' },
{ cardName: '카드명', user: '김길동', date: '2025-11-25 10:20', store: '식당', amount: 120000, usageType: '접대비' },
],
filters: [
{
key: 'user',
options: [
{ value: 'all', label: '전체' },
{ value: '홍길동', label: '홍길동' },
{ value: '김길동', label: '김길동' },
{ value: '이길동', label: '이길동' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 11000000,
totalColumnKey: 'amount',
},
},
me3: {
title: '당월 발행어음 상세',
summaryCards: [
{ label: '당월 발행어음 사용', value: 3123000, unit: '원' },
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
],
barChart: {
title: '월별 발행어음 추이',
data: [
{ name: '1월', value: 2000000 },
{ name: '2월', value: 2500000 },
{ name: '3월', value: 2200000 },
{ name: '4월', value: 2800000 },
{ name: '5월', value: 2600000 },
{ name: '6월', value: 3000000 },
{ name: '7월', value: 3123000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
horizontalBarChart: {
title: '당월 거래처별 발행어음',
data: [
{ name: '거래처1', value: 50000000 },
{ name: '거래처2', value: 35000000 },
{ name: '거래처3', value: 20000000 },
{ name: '거래처4', value: 6000000 },
],
color: '#60A5FA',
},
table: {
title: '일별 발행어음 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'vendor', label: '거래처', align: 'left' },
{ key: 'issueDate', label: '발행일', align: 'center', format: 'date' },
{ key: 'dueDate', label: '만기일', align: 'center', format: 'date' },
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
{ key: 'status', label: '상태', align: 'center' },
],
data: [
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 105000000, status: '보관중' },
],
filters: [
{
key: 'vendor',
options: [
{ value: 'all', label: '전체' },
{ value: '회사명', label: '회사명' },
],
defaultValue: 'all',
},
{
key: 'status',
options: [
{ value: 'all', label: '전체' },
{ value: '보관중', label: '보관중' },
{ value: '만기임박', label: '만기임박' },
{ value: '만기경과', label: '만기경과' },
{ value: '결제완료', label: '결제완료' },
{ value: '부도', label: '부도' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 111000000,
totalColumnKey: 'amount',
},
},
me4: {
title: '당월 지출 예상 상세',
summaryCards: [
{ label: '당월 지출 예상', value: 6000000, unit: '원' },
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
{ label: '총 계좌 잔액', value: 10000000, unit: '원' },
],
table: {
title: '당월 지출 승인 내역서',
columns: [
{ key: 'paymentDate', label: '예상 지급일', align: 'center' },
{ key: 'item', label: '항목', align: 'left' },
{ key: 'amount', label: '지출금액', align: 'right', format: 'currency', highlightColor: 'red' },
{ key: 'vendor', label: '거래처', align: 'center' },
{ key: 'account', label: '계좌', align: 'center' },
],
data: [
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '(발행 어음) 123123123', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '거래처명 12월분', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
{ paymentDate: '2025-12-12', item: '적요 내용', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
],
filters: [
{
key: 'vendor',
options: [
{ value: 'all', label: '전체' },
{ value: '회사명', label: '회사명' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '2025/12 계',
totalValue: 6000000,
totalColumnKey: 'amount',
footerSummary: [
{ label: '지출 합계', value: 6000000 },
{ label: '계좌 잔액', value: 10000000 },
{ label: '최종 차액', value: 4000000 },
],
},
},
};
return configs[cardId] || null;
}
export function getMonthlyExpenseModalConfig(_cardId: string): DetailModalConfig | null {
return null;
}

View File

@@ -2,90 +2,9 @@ import type { DetailModalConfig } from '../types';
/**
* 부가세 모달 설정
* 모든 카드가 동일한 상세 모달
* API 연동 완료 — useVatDetail hook이 실제 데이터 반환
* 이 함수는 하위 호환용으로 유지하되 null 반환
*/
export function getVatModalConfig(): DetailModalConfig {
return {
title: '예상 납부세액',
summaryCards: [],
// 세액 산출 내역 테이블
referenceTable: {
title: '2026년 1사분기 세액 산출 내역',
columns: [
{ key: 'category', label: '구분', align: 'center' },
{ key: 'amount', label: '금액', align: 'right' },
{ key: 'note', label: '비고', align: 'left' },
],
data: [
{ category: '매출세액', amount: '11,000,000', note: '과세매출 X 10%' },
{ category: '매입세액', amount: '1,000,000', note: '공제대상 매입 X 10%' },
{ category: '경감·공제세액', amount: '0', note: '해당없음' },
],
},
// 예상 납부세액 계산
calculationCards: {
title: '예상 납부세액 계산',
cards: [
{ label: '매출세액', value: 11000000, unit: '원' },
{ label: '매입세액', value: 1000000, unit: '원', operator: '-' },
{ label: '경감·공제세액', value: 0, unit: '원', operator: '-' },
{ label: '예상 납부세액', value: 10000000, unit: '원', operator: '=' },
],
},
// 세금계산서 미발행/미수취 내역
table: {
title: '세금계산서 미발행/미수취 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'type', label: '구분', align: 'center' },
{ key: 'issueDate', label: '발행일자', align: 'center', format: 'date' },
{ key: 'vendor', label: '거래처', align: 'left' },
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' },
{ key: 'invoiceStatus', label: '세금계산서 발행', align: 'center' },
],
data: [
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처1', vat: 11000000, invoiceStatus: '미발행' },
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처2', vat: 11000000, invoiceStatus: '미수취' },
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처3', vat: 11000000, invoiceStatus: '미발행' },
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처4', vat: 11000000, invoiceStatus: '미수취' },
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처5', vat: 11000000, invoiceStatus: '미발행' },
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처6', vat: 11000000, invoiceStatus: '미수취' },
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처7', vat: 11000000, invoiceStatus: '미발행' },
],
filters: [
{
key: 'type',
options: [
{ value: 'all', label: '전체' },
{ value: '매출', label: '매출' },
{ value: '매입', label: '매입' },
],
defaultValue: 'all',
},
{
key: 'invoiceStatus',
options: [
{ value: 'all', label: '전체' },
{ value: '미발행', label: '미발행' },
{ value: '미수취', label: '미수취' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 111000000,
totalColumnKey: 'vat',
},
};
}
export function getVatModalConfig(): DetailModalConfig | null {
return null;
}

View File

@@ -2,142 +2,9 @@ import type { DetailModalConfig } from '../types';
/**
* 복리후생비 현황 모달 설정
*
* @deprecated 정적 목업 데이터 - API 연동 후에는 useWelfareDetail hook 사용 권장
*
* API 연동 방법:
* 1. useWelfareDetail hook 호출하여 modalConfig 가져오기
* 2. API 호출 실패 시 이 fallback config 사용
*
* @example
* const { modalConfig, loading, error, refetch } = useWelfareDetail({
* calculationType: 'fixed',
* year: 2026,
* quarter: 1,
* });
* const config = modalConfig ?? getWelfareModalConfig('fixed'); // fallback
*
* @param calculationType - 계산 방식 ('fixed': 직원당 정액 금액/월, 'ratio': 연봉 총액 비율)
* API 연동 완료 — useWelfareDetail hook이 실제 데이터 반환
* 이 함수는 하위 호환용으로 유지하되 null 반환
*/
export function getWelfareModalConfig(calculationType: 'fixed' | 'ratio'): DetailModalConfig {
// 계산 방식에 따른 조건부 calculationCards 생성
const calculationCards = calculationType === 'fixed'
? {
// 직원당 정액 금액/월 방식
title: '복리후생비 계산',
subtitle: '직원당 정액 금액/월 200,000원',
cards: [
{ label: '직원 수', value: 20, unit: '명' },
{ label: '연간 직원당 월급 금액', value: 2400000, unit: '원', operator: '×' as const },
{ label: '당해년도 복리후생비 총 한도', value: 48000000, unit: '원', operator: '=' as const },
],
}
: {
// 연봉 총액 비율 방식
title: '복리후생비 계산',
subtitle: '연봉 총액 기준 비율 20.5%',
cards: [
{ label: '연봉 총액', value: 1000000000, unit: '원' },
{ label: '비율', value: 20.5, unit: '%', operator: '×' as const },
{ label: '당해년도 복리후생비 총 한도', value: 205000000, unit: '원', operator: '=' as const },
],
};
return {
title: '복리후생비 상세',
summaryCards: [
// 1행: 당해년도 기준
{ label: '당해년도 복리후생비 계정', value: 3123000, unit: '원' },
{ label: '당해년도 복리후생비 한도', value: 600000, unit: '원' },
{ label: '당해년도 복리후생비 사용', value: 6000000, unit: '원' },
{ label: '당해년도 잔여한도', value: 0, unit: '원' },
// 2행: 1사분기 기준
{ label: '1사분기 복리후생비 총 한도', value: 3123000, unit: '원' },
{ label: '1사분기 복리후생비 잔여한도', value: 6000000, unit: '원' },
{ label: '1사분기 복리후생비 사용금액', value: 6000000, unit: '원' },
{ label: '1사분기 복리후생비 초과 금액', value: 6000000, unit: '원' },
],
barChart: {
title: '월별 복리후생비 사용 추이',
data: [
{ name: '1월', value: 1500000 },
{ name: '2월', value: 1800000 },
{ name: '3월', value: 2200000 },
{ name: '4월', value: 1900000 },
{ name: '5월', value: 2100000 },
{ name: '6월', value: 1700000 },
],
dataKey: 'value',
xAxisKey: 'name',
color: '#60A5FA',
},
pieChart: {
title: '항목별 사용 비율',
data: [
{ name: '식비', value: 55000000, percentage: 55, color: '#FBBF24' },
{ name: '건강검진', value: 25000000, percentage: 5, color: '#60A5FA' },
{ name: '경조사비', value: 10000000, percentage: 10, color: '#F87171' },
{ name: '기타', value: 10000000, percentage: 30, color: '#34D399' },
],
},
table: {
title: '일별 복리후생비 사용 내역',
columns: [
{ key: 'no', label: 'No.', align: 'center' },
{ key: 'cardName', label: '카드명', align: 'left' },
{ key: 'user', label: '사용자', align: 'center' },
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
{ key: 'store', label: '가맹점명', align: 'left' },
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
{ key: 'usageType', label: '사용항목', align: 'center' },
],
data: [
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '식비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, usageType: '건강검진' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, usageType: '경조사비' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, usageType: '기타' },
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, usageType: '식비' },
],
filters: [
{
key: 'usageType',
options: [
{ value: 'all', label: '전체' },
{ value: '식비', label: '식비' },
{ value: '건강검진', label: '건강검진' },
{ value: '경조사비', label: '경조사비' },
{ value: '기타', label: '기타' },
],
defaultValue: 'all',
},
{
key: 'sortOrder',
options: [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '등록순' },
{ value: 'amountDesc', label: '금액 높은순' },
{ value: 'amountAsc', label: '금액 낮은순' },
],
defaultValue: 'latest',
},
],
showTotal: true,
totalLabel: '합계',
totalValue: 11000000,
totalColumnKey: 'amount',
},
// 복리후생비 계산 (조건부 - calculationType에 따라)
calculationCards,
// 복리후생비 현황 (분기별 테이블)
quarterlyTable: {
title: '복리후생비 현황',
rows: [
{ label: '한도금액', q1: 12000000, q2: 12000000, q3: 12000000, q4: 12000000, total: 48000000 },
{ label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
{ label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' },
{ label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' },
{ label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' },
],
},
};
export function getWelfareModalConfig(_calculationType: 'fixed' | 'ratio'): DetailModalConfig | null {
return null;
}

View File

@@ -1,6 +1,5 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { X } from 'lucide-react';
import {
Dialog,
@@ -8,682 +7,31 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { cn } from '@/lib/utils';
import type {
DetailModalConfig,
SummaryCardData,
BarChartConfig,
PieChartConfig,
HorizontalBarChartConfig,
TableConfig,
TableFilterConfig,
ComparisonSectionConfig,
ReferenceTableConfig,
CalculationCardsConfig,
QuarterlyTableConfig,
} from '../types';
import type { DetailModalConfig } from '../types';
import {
DateFilterSection,
PeriodSelectSection,
SummaryCard,
ReviewCardsSection,
BarChartSection,
PieChartSection,
HorizontalBarChartSection,
ComparisonSection,
CalculationCardsSection,
QuarterlyTableSection,
ReferenceTableSection,
TableSection,
} from './DetailModalSections';
interface DetailModalProps {
isOpen: boolean;
onClose: () => void;
config: DetailModalConfig;
onDateFilterChange?: (params: { startDate: string; endDate: string; search: string }) => void;
}
/**
* 금액 포맷 함수
*/
const formatCurrency = (value: number): string => {
return new Intl.NumberFormat('ko-KR').format(value);
};
/**
* 요약 카드 컴포넌트 - 모바일 반응형 지원
*/
const SummaryCard = ({ data }: { data: SummaryCardData }) => {
const displayValue = typeof data.value === 'number'
? formatCurrency(data.value) + (data.unit || '원')
: data.value;
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
<p className="text-xs sm:text-sm text-gray-500 mb-1">{data.label}</p>
<p className={cn(
"text-lg sm:text-2xl font-bold break-all",
data.isComparison && (data.isPositive ? "text-blue-600" : "text-red-600")
)}>
{data.isComparison && !data.isPositive && typeof data.value === 'string' && !data.value.startsWith('-') ? '-' : ''}
{displayValue}
</p>
</div>
);
};
/**
* 막대 차트 컴포넌트 - 모바일 반응형 지원
*/
const BarChartSection = ({ config }: { config: BarChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="h-[150px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={config.data} margin={{ top: 5, right: 5, left: -25, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
<XAxis
dataKey={config.xAxisKey}
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fill: '#6B7280' }}
interval={0}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#6B7280' }}
tickFormatter={(value) => value >= 10000 ? `${value / 10000}` : value}
width={35}
/>
<Tooltip
formatter={(value) => [formatCurrency(value as number) + '원', '']}
contentStyle={{ fontSize: 12 }}
/>
<Bar
dataKey={config.dataKey}
fill={config.color || '#60A5FA'}
radius={[4, 4, 0, 0]}
maxBarSize={30}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
/**
* 도넛 차트 컴포넌트 - 모바일 반응형 지원
*/
const PieChartSection = ({ config }: { config: PieChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 overflow-hidden">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
{/* 도넛 차트 - 중앙 정렬, 모바일 크기 조절 */}
<div className="flex justify-center mb-4">
<PieChart width={100} height={100}>
<Pie
data={config.data as unknown as Array<Record<string, unknown>>}
cx={50}
cy={50}
innerRadius={28}
outerRadius={45}
paddingAngle={2}
dataKey="value"
>
{config.data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
</PieChart>
</div>
{/* 범례 - 세로 배치 (모바일 최적화) */}
<div className="space-y-2">
{config.data.map((item, index) => (
<div key={index} className="flex items-center justify-between text-xs sm:text-sm gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-shrink">
<div
className="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-600 truncate">{item.name}</span>
<span className="text-gray-400 flex-shrink-0">{item.percentage}%</span>
</div>
<span className="font-medium text-gray-900 flex-shrink-0">
{formatCurrency(item.value)}
</span>
</div>
))}
</div>
</div>
);
};
/**
* 가로 막대 차트 컴포넌트
*/
const HorizontalBarChartSection = ({ config }: { config: HorizontalBarChartConfig }) => {
const maxValue = Math.max(...config.data.map(d => d.value));
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="space-y-3">
{config.data.map((item, index) => (
<div key={index} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">{item.name}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.value)}
</span>
</div>
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${(item.value / maxValue) * 100}%`,
backgroundColor: config.color || '#60A5FA',
}}
/>
</div>
</div>
))}
</div>
</div>
);
};
/**
* VS 비교 섹션 컴포넌트
*/
const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
const formatValue = (value: string | number, unit?: string): string => {
if (typeof value === 'number') {
return formatCurrency(value) + (unit || '원');
}
return value;
};
const borderColorClass = {
orange: 'border-orange-400',
blue: 'border-blue-400',
};
const titleBgClass = {
orange: 'bg-orange-50',
blue: 'bg-blue-50',
};
return (
<div className="flex items-stretch gap-4">
{/* 왼쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.leftBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.leftBox.borderColor]
)}>
{config.leftBox.title}
</div>
<div className="p-4 space-y-3">
{config.leftBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
{/* VS 영역 */}
<div className="flex flex-col items-center justify-center px-4">
<span className="text-2xl font-bold text-gray-400 mb-2">VS</span>
<div className="bg-red-50 rounded-lg px-4 py-3 text-center min-w-[180px]">
<p className="text-xs text-gray-600 mb-1">{config.vsLabel}</p>
<p className="text-xl font-bold text-red-500">
{typeof config.vsValue === 'number'
? formatCurrency(config.vsValue) + '원'
: config.vsValue}
</p>
{config.vsSubLabel && (
<p className="text-xs text-gray-500 mt-1">{config.vsSubLabel}</p>
)}
{/* VS 세부 항목 */}
{config.vsBreakdown && config.vsBreakdown.length > 0 && (
<div className="mt-2 pt-2 border-t border-red-200 space-y-1">
{config.vsBreakdown.map((item, index) => (
<div key={index} className="flex justify-between text-xs">
<span className="text-gray-600">{item.label}</span>
<span className="font-medium text-gray-700">
{typeof item.value === 'number'
? formatCurrency(item.value) + (item.unit || '원')
: item.value}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* 오른쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.rightBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.rightBox.borderColor]
)}>
{config.rightBox.title}
</div>
<div className="p-4 space-y-3">
{config.rightBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
</div>
);
};
/**
* 계산 카드 섹션 컴포넌트 (접대비 계산 등)
*/
const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => {
const isResultCard = (index: number, operator?: string) => {
// '=' 연산자가 있는 카드는 결과 카드로 강조
return operator === '=';
};
return (
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<h4 className="font-medium text-gray-800">{config.title}</h4>
{config.subtitle && (
<span className="text-sm text-gray-500">{config.subtitle}</span>
)}
</div>
<div className="flex items-center gap-3">
{config.cards.map((card, index) => (
<div key={index} className="flex items-center gap-3">
{/* 연산자 표시 (첫 번째 카드 제외) */}
{index > 0 && card.operator && (
<span className="text-3xl font-bold text-gray-400">
{card.operator}
</span>
)}
{/* 카드 */}
<div className={cn(
"rounded-lg p-5 min-w-[180px] text-center border",
isResultCard(index, card.operator)
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
)}>
<p className={cn(
"text-sm mb-2",
isResultCard(index, card.operator) ? "text-blue-600" : "text-gray-500"
)}>
{card.label}
</p>
<p className={cn(
"text-2xl font-bold",
isResultCard(index, card.operator) ? "text-blue-700" : "text-gray-900"
)}>
{formatCurrency(card.value)}{card.unit || '원'}
</p>
</div>
</div>
))}
</div>
</div>
);
};
/**
* 분기별 테이블 섹션 컴포넌트 (접대비 현황 등) - 가로 스크롤 지원
*/
const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => {
const formatValue = (value: number | string | undefined): string => {
if (value === undefined) return '-';
if (typeof value === 'number') return formatCurrency(value);
return value;
};
return (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-auto">
<table className="w-full min-w-[500px]">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-left"></th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">1</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">2</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">3</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">4</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center"></th>
</tr>
</thead>
<tbody>
{config.rows.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
<td className="px-4 py-3 text-sm text-gray-700 font-medium">{row.label}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q1)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q2)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q3)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q4)}</td>
<td className="px-4 py-3 text-sm text-gray-900 text-center font-medium">{formatValue(row.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
/**
* 참조 테이블 컴포넌트 (필터 없는 정보성 테이블) - 가로 스크롤 지원
*/
const ReferenceTableSection = ({ config }: { config: ReferenceTableConfig }) => {
const getAlignClass = (align?: string): string => {
switch (align) {
case 'center':
return 'text-center';
case 'right':
return 'text-right';
default:
return 'text-left';
}
};
return (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-auto">
<table className="w-full min-w-[400px]">
<thead>
<tr className="bg-gray-100">
{config.columns.map((column) => (
<th
key={column.key}
className={cn(
"px-4 py-3 text-xs font-medium text-gray-600",
getAlignClass(column.align)
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{config.data.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm text-gray-700",
getAlignClass(column.align)
)}
>
{String(row[column.key] ?? '-')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
/**
* 테이블 컴포넌트
*/
const TableSection = ({ config }: { config: TableConfig }) => {
const [filters, setFilters] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
config.filters?.forEach((filter) => {
initial[filter.key] = filter.defaultValue;
});
return initial;
});
const handleFilterChange = useCallback((key: string, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
// 필터링된 데이터
const filteredData = useMemo(() => {
// 데이터가 없는 경우 빈 배열 반환
if (!config.data || !Array.isArray(config.data)) {
return [];
}
let result = [...config.data];
// 각 필터 적용 (sortOrder는 정렬용이므로 제외)
config.filters?.forEach((filter) => {
if (filter.key === 'sortOrder') return; // 정렬 필터는 값 필터링에서 제외
const filterValue = filters[filter.key];
if (filterValue && filterValue !== 'all') {
result = result.filter((row) => row[filter.key] === filterValue);
}
});
// 정렬 필터 적용 (sortOrder가 있는 경우)
if (filters['sortOrder']) {
const sortOrder = filters['sortOrder'];
result.sort((a, b) => {
// 금액 정렬
if (sortOrder === 'amountDesc') {
return (b['amount'] as number) - (a['amount'] as number);
}
if (sortOrder === 'amountAsc') {
return (a['amount'] as number) - (b['amount'] as number);
}
// 날짜 정렬
const dateA = new Date(a['date'] as string).getTime();
const dateB = new Date(b['date'] as string).getTime();
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
});
}
return result;
}, [config.data, config.filters, filters]);
// 셀 값 포맷팅
const formatCellValue = (value: unknown, format?: string): string => {
if (value === null || value === undefined) return '-';
switch (format) {
case 'currency':
return typeof value === 'number' ? formatCurrency(value) : String(value);
case 'number':
return typeof value === 'number' ? formatCurrency(value) : String(value);
case 'date':
return String(value);
default:
return String(value);
}
};
// 셀 정렬 클래스
const getAlignClass = (align?: string): string => {
switch (align) {
case 'center':
return 'text-center';
case 'right':
return 'text-right';
default:
return 'text-left';
}
};
return (
<div className="mt-6">
{/* 테이블 헤더 */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-800">{config.title}</h4>
<span className="text-sm text-gray-500"> {filteredData.length}</span>
</div>
{/* 필터 영역 */}
{config.filters && config.filters.length > 0 && (
<div className="flex items-center gap-2">
{config.filters.map((filter) => (
<Select
key={filter.key}
value={filters[filter.key]}
onValueChange={(value) => handleFilterChange(filter.key, value)}
>
<SelectTrigger className="h-8 w-auto min-w-[80px] w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{filter.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
)}
</div>
{/* 테이블 - 가로 스크롤 지원 */}
<div className="border rounded-lg max-h-[400px] overflow-auto">
<table className="w-full min-w-[600px]">
<thead className="sticky top-0 z-10">
<tr className="bg-gray-100">
{config.columns.map((column) => (
<th
key={column.key}
className={cn(
"px-4 py-3 text-xs font-medium text-gray-600",
getAlignClass(column.align),
column.width && `w-[${column.width}]`
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{filteredData.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => {
const cellValue = column.key === 'no'
? rowIndex + 1
: formatCellValue(row[column.key], column.format);
const isHighlighted = column.highlightValue && String(row[column.key]) === column.highlightValue;
// highlightColor 클래스 매핑
const highlightColorClass = column.highlightColor ? {
red: 'text-red-500',
orange: 'text-orange-500',
blue: 'text-blue-500',
green: 'text-green-500',
}[column.highlightColor] : '';
return (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align),
isHighlighted && "text-orange-500 font-medium",
highlightColorClass
)}
>
{cellValue}
</td>
);
})}
</tr>
))}
{/* 합계 행 */}
{config.showTotal && (
<tr className="border-t-2 border-gray-200 bg-gray-50 font-medium">
{config.columns.map((column, colIndex) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align)
)}
>
{column.key === config.totalColumnKey
? (typeof config.totalValue === 'number'
? formatCurrency(config.totalValue)
: config.totalValue)
: (colIndex === 0 ? config.totalLabel || '합계' : '')}
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
{/* 하단 다중 합계 섹션 */}
{config.footerSummary && config.footerSummary.length > 0 && (
<div className="mt-4 border rounded-lg bg-gray-50 p-4">
<div className="space-y-2">
{config.footerSummary.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm text-gray-600">{item.label}</span>
<span className="font-medium text-gray-900">
{typeof item.value === 'number'
? formatCurrency(item.value)
: item.value}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
};
/**
* 상세 모달 공통 컴포넌트
*/
export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
export function DetailModal({ isOpen, onClose, config, onDateFilterChange }: DetailModalProps) {
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()} >
<DialogContent className="!w-[95vw] sm:!w-[90vw] md:!w-[85vw] !max-w-[1600px] max-h-[90vh] overflow-auto p-0">
@@ -702,6 +50,16 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
</DialogHeader>
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
{/* 기간선택기 영역 */}
{config.dateFilter?.enabled && (
<DateFilterSection config={config.dateFilter} onFilterChange={onDateFilterChange} />
)}
{/* 신고기간 셀렉트 영역 */}
{config.periodSelect?.enabled && (
<PeriodSelectSection config={config.periodSelect} />
)}
{/* 요약 카드 영역 - 모바일: 세로배치 */}
{config.summaryCards.length > 0 && (
<div className={cn(
@@ -716,6 +74,11 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
</div>
)}
{/* 검토 필요 카드 영역 */}
{config.reviewCards && (
<ReviewCardsSection config={config.reviewCards} />
)}
{/* 차트 영역 */}
{(config.barChart || config.pieChart || config.horizontalBarChart) && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">

View File

@@ -0,0 +1,739 @@
'use client';
import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Search as SearchIcon } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
import { cn } from '@/lib/utils';
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
import type {
DateFilterConfig,
PeriodSelectConfig,
SummaryCardData,
BarChartConfig,
PieChartConfig,
HorizontalBarChartConfig,
TableConfig,
ComparisonSectionConfig,
ReferenceTableConfig,
CalculationCardsConfig,
QuarterlyTableConfig,
ReviewCardsConfig,
} from '../types';
// ============================================
// 공통 유틸리티
// ============================================
// 필터 섹션
// ============================================
export const DateFilterSection = ({ config, onFilterChange }: { config: DateFilterConfig; onFilterChange?: (params: { startDate: string; endDate: string; search: string }) => void }) => {
const today = new Date();
const [startDate, setStartDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth(), 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const [endDate, setEndDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
});
const [searchText, setSearchText] = useState('');
const isInitialMount = useRef(true);
// 날짜 변경 시 자동 조회 (다른 페이지와 동일한 UX)
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onFilterChange?.({ startDate, endDate, search: searchText });
}, [startDate, endDate]);
const handleSearch = useCallback(() => {
onFilterChange?.({ startDate, endDate, search: searchText });
}, [startDate, endDate, searchText, onFilterChange]);
const handleSearchKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSearch();
}, [handleSearch]);
return (
<div className="pb-4 border-b">
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
<div className="flex items-center gap-2 ml-auto">
{config.showSearch !== false && (
<div className="relative">
<SearchIcon className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-gray-400" />
<Input
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onKeyDown={handleSearchKeyDown}
placeholder="검색"
className="h-8 pl-7 pr-3 text-xs w-[140px]"
/>
</div>
)}
</div>
}
/>
</div>
);
};
export const PeriodSelectSection = ({ config }: { config: PeriodSelectConfig }) => {
const [selected, setSelected] = useState(config.defaultValue || config.options[0]?.value || '');
const handleChange = useCallback((value: string) => {
setSelected(value);
config.onPeriodChange?.(value);
}, [config]);
return (
<div className="flex items-center gap-2 pb-4 border-b">
<span className="text-sm text-gray-600 font-medium"></span>
<Select value={selected} onValueChange={handleChange}>
<SelectTrigger className="h-8 w-auto min-w-[200px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{config.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
// ============================================
// 카드 섹션
// ============================================
export const SummaryCard = ({ data }: { data: SummaryCardData }) => {
const displayValue = typeof data.value === 'number'
? formatCurrency(data.value) + (data.unit || '원')
: data.value;
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
<p className="text-xs sm:text-sm text-gray-500 mb-1">{data.label}</p>
<p className={cn(
"text-lg sm:text-2xl font-bold break-all",
data.isComparison && (data.isPositive ? "text-blue-600" : "text-red-600")
)}>
{data.isComparison && !data.isPositive && typeof data.value === 'string' && !data.value.startsWith('-') ? '-' : ''}
{displayValue}
</p>
</div>
);
};
export const ReviewCardsSection = ({ config }: { config: ReviewCardsConfig }) => {
return (
<div>
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-3">
{config.cards.map((card, index) => (
<div
key={index}
className="bg-orange-50 border border-orange-200 rounded-lg p-3 sm:p-4"
>
<p className="text-xs sm:text-sm text-orange-700 font-medium mb-1">{card.label}</p>
<p className="text-lg sm:text-xl font-bold text-orange-900">
{formatCurrency(card.amount)}
</p>
<p className="text-xs text-orange-600 mt-1">{card.subLabel}</p>
</div>
))}
</div>
</div>
);
};
export const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => {
const isResultCard = (_index: number, operator?: string) => {
return operator === '=';
};
return (
<div className="mt-6">
<div className="flex items-center gap-2 mb-3">
<h4 className="font-medium text-gray-800">{config.title}</h4>
{config.subtitle && (
<span className="text-sm text-gray-500">{config.subtitle}</span>
)}
</div>
<div className="flex items-center gap-3">
{config.cards.map((card, index) => (
<div key={index} className="flex items-center gap-3">
{index > 0 && card.operator && (
<span className="text-3xl font-bold text-gray-400">
{card.operator}
</span>
)}
<div className={cn(
"rounded-lg p-5 min-w-[180px] text-center border",
isResultCard(index, card.operator)
? "bg-blue-50 border-blue-200"
: "bg-gray-50 border-gray-200"
)}>
<p className={cn(
"text-sm mb-2",
isResultCard(index, card.operator) ? "text-blue-600" : "text-gray-500"
)}>
{card.label}
</p>
<p className={cn(
"text-2xl font-bold",
isResultCard(index, card.operator) ? "text-blue-700" : "text-gray-900"
)}>
{formatCurrency(card.value)}{card.unit || '원'}
</p>
</div>
</div>
))}
</div>
</div>
);
};
// ============================================
// 차트 섹션
// ============================================
export const BarChartSection = ({ config }: { config: BarChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="h-[150px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={config.data} margin={{ top: 5, right: 5, left: -25, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
<XAxis
dataKey={config.xAxisKey}
axisLine={false}
tickLine={false}
tick={{ fontSize: 10, fill: '#6B7280' }}
interval={0}
/>
<YAxis
axisLine={false}
tickLine={false}
tick={{ fontSize: 9, fill: '#6B7280' }}
tickFormatter={(value) => value >= 10000 ? `${value / 10000}` : value}
width={35}
/>
<Tooltip
formatter={(value) => [formatCurrency(value as number) + '원', '']}
contentStyle={{ fontSize: 12 }}
/>
<Bar
dataKey={config.dataKey}
fill={config.color || '#60A5FA'}
radius={[4, 4, 0, 0]}
maxBarSize={30}
/>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};
export const PieChartSection = ({ config }: { config: PieChartConfig }) => {
return (
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 overflow-hidden">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="flex justify-center mb-4">
<PieChart width={100} height={100}>
<Pie
data={config.data as unknown as Array<Record<string, unknown>>}
cx={50}
cy={50}
innerRadius={28}
outerRadius={45}
paddingAngle={2}
dataKey="value"
>
{config.data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
</PieChart>
</div>
<div className="space-y-2">
{config.data.map((item, index) => (
<div key={index} className="flex items-center justify-between text-xs sm:text-sm gap-2">
<div className="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-shrink">
<div
className="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: item.color }}
/>
<span className="text-gray-600 truncate">{item.name}</span>
<span className="text-gray-400 flex-shrink-0">{item.percentage}%</span>
</div>
<span className="font-medium text-gray-900 flex-shrink-0">
{formatCurrency(item.value)}
</span>
</div>
))}
</div>
</div>
);
};
export const HorizontalBarChartSection = ({ config }: { config: HorizontalBarChartConfig }) => {
const maxValue = Math.max(...config.data.map(d => d.value));
return (
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
<div className="space-y-3">
{config.data.map((item, index) => (
<div key={index} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-600">{item.name}</span>
<span className="font-medium text-gray-900">
{formatCurrency(item.value)}
</span>
</div>
<div className="h-4 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${(item.value / maxValue) * 100}%`,
backgroundColor: config.color || '#60A5FA',
}}
/>
</div>
</div>
))}
</div>
</div>
);
};
// ============================================
// 비교 섹션
// ============================================
export const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => {
const formatValue = (value: string | number, unit?: string): string => {
if (typeof value === 'number') {
return formatCurrency(value) + (unit || '원');
}
return value;
};
const borderColorClass = {
orange: 'border-orange-400',
blue: 'border-blue-400',
};
const titleBgClass = {
orange: 'bg-orange-50',
blue: 'bg-blue-50',
};
return (
<div className="flex items-stretch gap-4">
{/* 왼쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.leftBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.leftBox.borderColor]
)}>
{config.leftBox.title}
</div>
<div className="p-4 space-y-3">
{config.leftBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
{/* VS 영역 */}
<div className="flex flex-col items-center justify-center px-4">
<span className="text-2xl font-bold text-gray-400 mb-2">VS</span>
<div className="bg-red-50 rounded-lg px-4 py-3 text-center min-w-[180px]">
<p className="text-xs text-gray-600 mb-1">{config.vsLabel}</p>
<p className="text-xl font-bold text-red-500">
{typeof config.vsValue === 'number'
? formatCurrency(config.vsValue) + '원'
: config.vsValue}
</p>
{config.vsSubLabel && (
<p className="text-xs text-gray-500 mt-1">{config.vsSubLabel}</p>
)}
{config.vsBreakdown && config.vsBreakdown.length > 0 && (
<div className="mt-2 pt-2 border-t border-red-200 space-y-1">
{config.vsBreakdown.map((item, index) => (
<div key={index} className="flex justify-between text-xs">
<span className="text-gray-600">{item.label}</span>
<span className="font-medium text-gray-700">
{typeof item.value === 'number'
? formatCurrency(item.value) + (item.unit || '원')
: item.value}
</span>
</div>
))}
</div>
)}
</div>
</div>
{/* 오른쪽 박스 */}
<div className={cn(
"flex-1 border-2 rounded-lg overflow-hidden",
borderColorClass[config.rightBox.borderColor]
)}>
<div className={cn(
"px-4 py-2 text-sm font-medium text-gray-700",
titleBgClass[config.rightBox.borderColor]
)}>
{config.rightBox.title}
</div>
<div className="p-4 space-y-3">
{config.rightBox.items.map((item, index) => (
<div key={index}>
<p className="text-xs text-gray-500 mb-1">{item.label}</p>
<p className="text-lg font-bold text-gray-900">
{formatValue(item.value, item.unit)}
</p>
</div>
))}
</div>
</div>
</div>
);
};
// ============================================
// 테이블 섹션
// ============================================
export const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => {
const formatValue = (value: number | string | undefined): string => {
if (value === undefined) return '-';
if (typeof value === 'number') return formatCurrency(value);
return value;
};
return (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-auto">
<table className="w-full min-w-[500px]">
<thead>
<tr className="bg-gray-100">
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-left"></th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">1</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">2</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">3</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center">4</th>
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-center"></th>
</tr>
</thead>
<tbody>
{config.rows.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
<td className="px-4 py-3 text-sm text-gray-700 font-medium">{row.label}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q1)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q2)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q3)}</td>
<td className="px-4 py-3 text-sm text-gray-700 text-center">{formatValue(row.q4)}</td>
<td className="px-4 py-3 text-sm text-gray-900 text-center font-medium">{formatValue(row.total)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export const ReferenceTableSection = ({ config }: { config: ReferenceTableConfig }) => {
const getAlignClass = (align?: string): string => {
switch (align) {
case 'center': return 'text-center';
case 'right': return 'text-right';
default: return 'text-left';
}
};
return (
<div className="mt-6">
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
<div className="border rounded-lg overflow-auto">
<table className="w-full min-w-[400px]">
<thead>
<tr className="bg-gray-100">
{config.columns.map((column) => (
<th
key={column.key}
className={cn(
"px-4 py-3 text-xs font-medium text-gray-600",
getAlignClass(column.align)
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{config.data.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm text-gray-700",
getAlignClass(column.align)
)}
>
{String(row[column.key] ?? '-')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
export const TableSection = ({ config }: { config: TableConfig }) => {
const [filters, setFilters] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
config.filters?.forEach((filter) => {
initial[filter.key] = filter.defaultValue;
});
return initial;
});
const handleFilterChange = useCallback((key: string, value: string) => {
setFilters((prev) => ({ ...prev, [key]: value }));
}, []);
const filteredData = useMemo(() => {
if (!config.data || !Array.isArray(config.data)) {
return [];
}
let result = [...config.data];
config.filters?.forEach((filter) => {
if (filter.key === 'sortOrder') return;
const filterValue = filters[filter.key];
if (filterValue && filterValue !== 'all') {
result = result.filter((row) => row[filter.key] === filterValue);
}
});
if (filters['sortOrder']) {
const sortOrder = filters['sortOrder'];
result.sort((a, b) => {
if (sortOrder === 'amountDesc') {
return (b['amount'] as number) - (a['amount'] as number);
}
if (sortOrder === 'amountAsc') {
return (a['amount'] as number) - (b['amount'] as number);
}
const dateA = new Date(a['date'] as string).getTime();
const dateB = new Date(b['date'] as string).getTime();
return sortOrder === 'latest' ? dateB - dateA : dateA - dateB;
});
}
return result;
}, [config.data, config.filters, filters]);
const formatCellValue = (value: unknown, format?: string): string => {
if (value === null || value === undefined) return '-';
switch (format) {
case 'currency':
case 'number':
return typeof value === 'number' ? formatCurrency(value) : String(value);
default:
return String(value);
}
};
const getAlignClass = (align?: string): string => {
switch (align) {
case 'center': return 'text-center';
case 'right': return 'text-right';
default: return 'text-left';
}
};
return (
<div className="mt-6">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<h4 className="font-medium text-gray-800">{config.title}</h4>
<span className="text-sm text-gray-500"> {filteredData.length}</span>
</div>
{config.filters && config.filters.length > 0 && (
<div className="flex items-center gap-2">
{config.filters.map((filter) => (
<Select
key={filter.key}
value={filters[filter.key]}
onValueChange={(value) => handleFilterChange(filter.key, value)}
>
<SelectTrigger className="h-8 w-auto min-w-[80px] w-auto text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{filter.options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
))}
</div>
)}
</div>
<div className="border rounded-lg max-h-[400px] overflow-auto">
<table className="w-full min-w-[600px]">
<thead className="sticky top-0 z-10">
<tr className="bg-gray-100">
{config.columns.map((column) => (
<th
key={column.key}
className={cn(
"px-4 py-3 text-xs font-medium text-gray-600",
getAlignClass(column.align),
column.width && `w-[${column.width}]`
)}
>
{column.label}
</th>
))}
</tr>
</thead>
<tbody>
{filteredData.map((row, rowIndex) => (
<tr
key={rowIndex}
className="border-t border-gray-100 hover:bg-gray-50"
>
{config.columns.map((column) => {
const cellValue = column.key === 'no'
? rowIndex + 1
: formatCellValue(row[column.key], column.format);
const isHighlighted = column.highlightValue && String(row[column.key]) === column.highlightValue;
const highlightColorClass = column.highlightColor ? {
red: 'text-red-500',
orange: 'text-orange-500',
blue: 'text-blue-500',
green: 'text-green-500',
}[column.highlightColor] : '';
return (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align),
isHighlighted && "text-orange-500 font-medium",
highlightColorClass
)}
>
{cellValue}
</td>
);
})}
</tr>
))}
{config.showTotal && (
<tr className="border-t-2 border-gray-200 bg-gray-50 font-medium">
{config.columns.map((column, colIndex) => (
<td
key={column.key}
className={cn(
"px-4 py-3 text-sm",
getAlignClass(column.align)
)}
>
{column.key === config.totalColumnKey
? (typeof config.totalValue === 'number'
? formatCurrency(config.totalValue)
: config.totalValue)
: (colIndex === 0 ? config.totalLabel || '합계' : '')}
</td>
))}
</tr>
)}
</tbody>
</table>
</div>
{config.footerSummary && config.footerSummary.length > 0 && (
<div className="mt-4 border rounded-lg bg-gray-50 p-4">
<div className="space-y-2">
{config.footerSummary.map((item, index) => (
<div key={index} className="flex items-center justify-between">
<span className="text-sm text-gray-600">{item.label}</span>
<span className="font-medium text-gray-900">
{typeof item.value === 'number'
? formatCurrency(item.value)
: item.value}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
};

View File

@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { TimePicker } from '@/components/ui/time-picker';
import { DatePicker } from '@/components/ui/date-picker';
import { DateRangePicker } from '@/components/ui/date-range-picker';
import {
Dialog,
DialogContent,
@@ -21,6 +21,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import type { CalendarScheduleItem } from '../types';
// 색상 옵션
@@ -59,6 +60,7 @@ interface ScheduleDetailModalProps {
schedule: CalendarScheduleItem | null;
onSave: (data: ScheduleFormData) => void;
onDelete?: (id: string) => void;
isEditable?: boolean;
}
export function ScheduleDetailModal({
@@ -67,6 +69,7 @@ export function ScheduleDetailModal({
schedule,
onSave,
onDelete,
isEditable = true,
}: ScheduleDetailModalProps) {
const isEditMode = schedule && schedule.id !== '';
@@ -128,7 +131,14 @@ export function ScheduleDetailModal({
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="w-[95vw] max-w-[480px] sm:max-w-[480px] p-4 sm:p-6 max-h-[90vh] overflow-y-auto">
<DialogHeader className="pb-2">
<DialogTitle className="text-lg font-bold"> </DialogTitle>
<DialogTitle className="text-lg font-bold flex items-center gap-2">
{!isEditable && (
<Badge variant="secondary" className="text-xs bg-gray-100 text-gray-600">
</Badge>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
@@ -139,6 +149,7 @@ export function ScheduleDetailModal({
value={formData.title}
onChange={(e) => handleFieldChange('title', e.target.value)}
placeholder="제목"
disabled={!isEditable}
/>
</div>
@@ -148,6 +159,7 @@ export function ScheduleDetailModal({
<Select
value={formData.department}
onValueChange={(value) => handleFieldChange('department', value)}
disabled={!isEditable}
>
<SelectTrigger>
<SelectValue placeholder="부서명" />
@@ -165,23 +177,15 @@ export function ScheduleDetailModal({
{/* 기간 */}
<div className="space-y-1.5">
<label className="text-sm font-medium text-gray-700"></label>
<div className="flex flex-col gap-2">
<DatePicker
value={formData.startDate}
onChange={(value) => handleFieldChange('startDate', value)}
size="sm"
className="w-full"
/>
<div className="flex items-center gap-2">
<span className="text-gray-400 text-xs">~</span>
<DatePicker
value={formData.endDate}
onChange={(value) => handleFieldChange('endDate', value)}
size="sm"
className="w-full"
/>
</div>
</div>
<DateRangePicker
startDate={formData.startDate}
endDate={formData.endDate}
onStartDateChange={(date) => handleFieldChange('startDate', date)}
onEndDateChange={(date) => handleFieldChange('endDate', date)}
size="sm"
className="w-full"
disabled={!isEditable}
/>
</div>
{/* 시간 */}
@@ -196,6 +200,7 @@ export function ScheduleDetailModal({
onCheckedChange={(checked) =>
handleFieldChange('isAllDay', checked === true)
}
disabled={!isEditable}
/>
<label htmlFor="isAllDay" className="text-sm text-gray-600 cursor-pointer">
@@ -236,9 +241,10 @@ export function ScheduleDetailModal({
formData.color === color.value
? 'ring-2 ring-offset-2 ring-gray-400'
: 'hover:scale-110'
}`}
onClick={() => handleFieldChange('color', color.value)}
} ${!isEditable ? 'opacity-50 cursor-not-allowed' : ''}`}
onClick={() => isEditable && handleFieldChange('color', color.value)}
title={color.label}
disabled={!isEditable}
/>
))}
</div>
@@ -252,26 +258,35 @@ export function ScheduleDetailModal({
onChange={(e) => handleFieldChange('content', e.target.value)}
placeholder="내용"
className="min-h-[100px] resize-none"
disabled={!isEditable}
/>
</div>
</div>
<DialogFooter className="flex flex-row gap-2 pt-2">
{isEditMode && onDelete && (
<Button
variant="outline"
onClick={handleDelete}
className="bg-gray-800 text-white hover:bg-gray-900"
>
{isEditable ? (
<>
{isEditMode && onDelete && (
<Button
variant="outline"
onClick={handleDelete}
className="bg-gray-800 text-white hover:bg-gray-900"
>
</Button>
)}
<Button
onClick={handleSave}
className="bg-gray-800 text-white hover:bg-gray-900"
>
{isEditMode ? '수정' : '등록'}
</Button>
</>
) : (
<Button variant="outline" onClick={handleCancel}>
</Button>
)}
<Button
onClick={handleSave}
className="bg-gray-800 text-white hover:bg-gray-900"
>
{isEditMode ? '수정' : '등록'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

View File

@@ -44,6 +44,22 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
tax: 'orange',
};
// 일정 타입별 라벨
const SCHEDULE_TYPE_LABELS: Record<string, string> = {
order: '생산',
construction: '시공',
schedule: '일정',
other: '기타',
};
// 일정 타입별 뱃지 색상
const SCHEDULE_TYPE_BADGE_COLORS: Record<string, string> = {
order: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
construction: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
schedule: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
other: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
};
// 이슈 뱃지별 색상
const ISSUE_BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
'수주등록': 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
@@ -453,6 +469,11 @@ export function CalendarSection({
return (
<div key={ev.id} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={`w-2 h-2 rounded-full shrink-0 ${dotColor}`} />
{isSelected && evType !== 'holiday' && evType !== 'tax' && evType !== 'issue' && (
<span className={`text-[10px] shrink-0 px-1 rounded ${SCHEDULE_TYPE_BADGE_COLORS[evType] || ''}`}>
{SCHEDULE_TYPE_LABELS[evType] || ''}
</span>
)}
<span className={isSelected ? '' : 'truncate'}>{cleanTitle}</span>
</div>
);
@@ -469,8 +490,8 @@ export function CalendarSection({
</div>
</div>
{/* 데스크탑: 기존 캘린더 + 상세 */}
<div className="hidden lg:grid lg:grid-cols-2 gap-6">
{/* 데스크탑: 캘린더 + 상세 (태블릿: 세로배치, 와이드: 가로배치) */}
<div className="hidden lg:grid lg:grid-cols-1 xl:grid-cols-2 gap-6">
{/* 캘린더 영역 */}
<div>
<ScheduleCalendar
@@ -554,7 +575,12 @@ export function CalendarSection({
className="p-3 border border-border rounded-lg hover:bg-muted/50 transition-colors cursor-pointer"
onClick={() => onScheduleClick?.(schedule)}
>
<div className="font-medium text-base text-foreground mb-1">{schedule.title}</div>
<div className="flex items-start gap-2 mb-1">
<Badge variant="secondary" className={`shrink-0 text-xs ${SCHEDULE_TYPE_BADGE_COLORS[schedule.type]}`}>
{SCHEDULE_TYPE_LABELS[schedule.type] || '일정'}
</Badge>
<span className="font-medium text-base text-foreground flex-1">{schedule.title}</span>
</div>
<div className="text-sm text-muted-foreground">{formatScheduleDetail(schedule)}</div>
</div>
))}

View File

@@ -1,22 +1,48 @@
'use client';
import { useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { CreditCard, Wallet, Receipt, AlertTriangle } from 'lucide-react';
import { CreditCard, Wallet, Receipt, AlertTriangle, Gift, CheckCircle2, ShieldAlert } from 'lucide-react';
import { formatKoreanAmount } from '@/lib/utils/amount';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { CardManagementData } from '../types';
// 카드별 아이콘 매핑
const CARD_ICONS = [CreditCard, Wallet, Receipt, AlertTriangle];
const CARD_THEMES: SectionColorTheme[] = ['blue', 'indigo', 'purple', 'orange'];
const CARD_ICONS = [CreditCard, Gift, Receipt, AlertTriangle, Wallet];
const CARD_THEMES: SectionColorTheme[] = ['blue', 'indigo', 'purple', 'orange', 'blue'];
interface CardManagementSectionProps {
data: CardManagementData;
onCardClick?: (cardId: string) => void;
}
/** subLabel에서 "미정리 N건", "미증빙 N건" 등의 건수를 파싱 */
function parseIssueCount(subLabel?: string): number {
if (!subLabel) return 0;
const match = subLabel.match(/(\d+)\s*건/);
return match ? parseInt(match[1], 10) : 0;
}
export function CardManagementSection({ data, onCardClick }: CardManagementSectionProps) {
const router = useRouter();
// 카드별 미정리/미증빙 건수 집계
const issueStats = useMemo(() => {
let totalCount = 0;
let totalAmount = 0;
const issueCards: string[] = [];
for (const card of data.cards) {
const count = parseIssueCount(card.subLabel);
if (count > 0 || card.isHighlighted) {
totalCount += count;
totalAmount += card.subAmount ?? 0;
issueCards.push(card.label);
}
}
return { totalCount, totalAmount, issueCards, hasIssues: totalCount > 0 };
}, [data.cards]);
const handleClick = (cardId: string) => {
if (onCardClick) {
onCardClick(cardId);
@@ -28,17 +54,54 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
return (
<CollapsibleDashboardCard
icon={<CreditCard style={{ color: '#ffffff' }} className="h-5 w-5" />}
title="카드/가지급금 관리"
subtitle="카드 및 가지급금 현황"
title="가지급금 현황"
subtitle="가지급금 관리 현황"
>
{data.warningBanner && (
<div className="bg-red-500 text-white text-sm font-medium px-4 py-2 rounded-lg mb-4 flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
{/* 상태 배너: 미정리 있으면 빨간 펄스, 정상이면 초록 */}
{issueStats.hasIssues ? (
<div className="relative overflow-hidden rounded-lg mb-4">
{/* 펄스 배경 */}
<div className="absolute inset-0 bg-red-500 animate-pulse opacity-20 rounded-lg" />
<div className="relative bg-red-50 dark:bg-red-900/40 border border-red-300 dark:border-red-700 px-4 py-3 rounded-lg flex items-center justify-between gap-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-full bg-red-500 flex items-center justify-center shrink-0">
<ShieldAlert className="h-4 w-4 text-white" />
</div>
<div>
<p className="text-sm font-bold text-red-700 dark:text-red-300">
{issueStats.totalCount}
</p>
<p className="text-xs text-red-600/80 dark:text-red-400/80">
{issueStats.issueCards.join(' · ')}
</p>
</div>
</div>
{issueStats.totalAmount > 0 && (
<div className="text-right shrink-0">
<p className="text-lg font-bold text-red-700 dark:text-red-300">
{formatKoreanAmount(issueStats.totalAmount)}
</p>
<p className="text-[11px] text-red-500/70 dark:text-red-400/60"> </p>
</div>
)}
</div>
</div>
) : (
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 px-4 py-2.5 rounded-lg mb-4 flex items-center gap-2.5">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<span className="text-sm font-medium text-green-700 dark:text-green-300"> </span>
</div>
)}
{/* 기존 warningBanner 호환 (issueStats과 별도 메시지가 있는 경우) */}
{data.warningBanner && issueStats.hasIssues && (
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs font-medium px-3 py-2 rounded-lg mb-4 flex items-center gap-2">
<AlertTriangle className="h-3.5 w-3.5" />
{data.warningBanner}
</div>
)}
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-5 gap-3 xs:gap-4 mb-4">
{data.cards.map((card, idx) => (
<AmountCardItem
key={card.id}

View File

@@ -53,7 +53,7 @@ export function DailyAttendanceSection({ data }: DailyAttendanceSectionProps) {
<Button
variant="ghost"
size="sm"
onClick={(e) => { e.stopPropagation(); router.push('/ko/hr/attendance'); }}
onClick={(e) => { e.stopPropagation(); router.push('/hr/attendance-management'); }}
className="text-white hover:bg-white/10 gap-1 text-xs"
>

View File

@@ -83,6 +83,12 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
}
>
{/* 공정 탭 */}
{data.processes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Factory className="h-10 w-10 mb-3 opacity-30" />
<p className="text-sm"> .</p>
</div>
) : (
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="mb-4">
{data.processes.map((process) => (
@@ -240,6 +246,7 @@ export function DailyProductionSection({ data, showShipment = true }: DailyProdu
</TabsContent>
))}
</Tabs>
)}
</CollapsibleDashboardCard>

View File

@@ -22,6 +22,7 @@ import {
Banknote,
CircleDollarSign,
LayoutGrid,
type LucideIcon,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Badge } from '@/components/ui/badge';
@@ -44,6 +45,7 @@ const formatUSD = (amount: number): string => {
interface EnhancedDailyReportSectionProps {
data: DailyReportData;
onClick?: () => void;
onExpenseDetailClick?: () => void;
}
const CARD_STYLES = [
@@ -53,10 +55,18 @@ const CARD_STYLES = [
{ bgClass: 'bg-orange-50 dark:bg-orange-900/30', borderClass: 'border-orange-200 dark:border-orange-800', iconBg: '#f97316', labelClass: 'text-orange-700 dark:text-orange-300', Icon: Clock },
];
export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyReportSectionProps) {
export function EnhancedDailyReportSection({ data, onClick, onExpenseDetailClick }: EnhancedDailyReportSectionProps) {
const router = useRouter();
const handleCardClick = (card: DailyReportData['cards'][number]) => {
// dr3 (미지급금 잔액): 클릭 동작 없음
if (card.id === 'dr3') return;
// dr4 (당월 예상 지출 합계): 상세 팝업 표시
if (card.id === 'dr4') {
onExpenseDetailClick?.();
return;
}
// dr1, dr2: path로 페이지 이동
if (card.path) {
router.push(card.path);
} else if (onClick) {
@@ -85,7 +95,7 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
return (
<div
key={card.id}
className={`rounded-xl p-4 cursor-pointer transition-all hover:shadow-lg border min-h-[110px] flex flex-col ${style.bgClass} ${style.borderClass}`}
className={`rounded-xl p-4 transition-all border min-h-[110px] flex flex-col ${style.bgClass} ${style.borderClass} ${card.id === 'dr3' ? 'cursor-default' : 'cursor-pointer hover:shadow-lg'}`}
onClick={() => handleCardClick(card)}
>
<div className="flex items-center gap-2 mb-3">
@@ -96,25 +106,21 @@ export function EnhancedDailyReportSection({ data, onClick }: EnhancedDailyRepor
{card.label}
</span>
</div>
<div className="flex items-end gap-2">
<span className="text-2xl font-bold text-foreground">
{card.displayValue
? card.displayValue
: card.currency === 'USD'
? formatUSD(card.amount)
: formatKoreanAmount(card.amount)}
</span>
{card.changeRate && (
<span
className={`flex items-center text-xs font-medium mb-1 ${card.changeDirection === 'up' ? 'text-red-500 dark:text-red-400' : 'text-blue-500 dark:text-blue-400'}`}
>
{card.changeDirection === 'up'
? <ArrowUpRight className="h-3 w-3" />
: <ArrowDownRight className="h-3 w-3" />}
{card.changeRate}
</span>
)}
<div className="text-2xl font-bold text-foreground">
{card.displayValue
? card.displayValue
: card.currency === 'USD'
? formatUSD(card.amount)
: formatKoreanAmount(card.amount)}
</div>
{/* 기획서 D1.7 기준: 자금현황 카드에 전일 대비 미표시 — 추후 필요 시 복원
{card.changeRate && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
전일 대비 {card.changeRate}
</div>
)}
*/}
</div>
);
})}
@@ -159,8 +165,8 @@ const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
'세금 신고': 'taxReport',
'신규 업체 등록': 'newVendor',
'연차': 'annualLeave',
'지각': 'lateness',
'결근': 'absence',
'차량': 'vehicle',
'장비': 'equipment',
'발주': 'purchase',
'결재 요청': 'approvalRequest',
};
@@ -190,6 +196,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
const router = useRouter();
const handleItemClick = (path: string) => {
if (!path) return;
router.push(path);
};
@@ -224,7 +231,7 @@ export function EnhancedStatusBoardSection({ items, itemSettings }: EnhancedStat
return (
<div
key={item.id}
className={`relative p-4 rounded-xl border cursor-pointer transition-all hover:scale-[1.02] hover:shadow-md min-h-[130px] flex flex-col ${isHighlighted ? 'bg-red-500 border-red-500 dark:bg-red-600 dark:border-red-600' : `${style.bgClass} ${style.borderClass}`}`}
className={`relative p-4 rounded-xl border transition-all min-h-[130px] flex flex-col ${item.path ? 'cursor-pointer hover:scale-[1.02] hover:shadow-md' : 'cursor-default'} ${isHighlighted ? 'bg-red-500 border-red-500 dark:bg-red-600 dark:border-red-600' : `${style.bgClass} ${style.borderClass}`}`}
onClick={() => handleItemClick(item.path)}
>
{/* 아이콘 + 라벨 */}
@@ -274,94 +281,62 @@ interface EnhancedMonthlyExpenseSectionProps {
onCardClick?: (cardId: string) => void;
}
// 당월 예상 지출 카드 설정
const EXPENSE_CARD_CONFIGS: Array<{
icon: LucideIcon;
iconBg: string;
bgClass: string;
labelClass: string;
defaultLabel: string;
defaultId: string;
}> = [
{ icon: Receipt, iconBg: '#8b5cf6', bgClass: 'bg-purple-50 border-purple-200 dark:bg-purple-900/30 dark:border-purple-800', labelClass: 'text-purple-700 dark:text-purple-300', defaultLabel: '매입', defaultId: 'me1' },
{ icon: CreditCard, iconBg: '#3b82f6', bgClass: 'bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800', labelClass: 'text-blue-700 dark:text-blue-300', defaultLabel: '카드', defaultId: 'me2' },
{ icon: Banknote, iconBg: '#f59e0b', bgClass: 'bg-amber-50 border-amber-200 dark:bg-amber-900/30 dark:border-amber-800', labelClass: 'text-amber-700 dark:text-amber-300', defaultLabel: '발행어음', defaultId: 'me3' },
];
export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMonthlyExpenseSectionProps) {
// 총 예상 지출 계산 (API에서 문자열로 올 수 있으므로 Number로 변환)
const totalAmount = data.cards.reduce((sum, card) => sum + (Number(card?.amount) || 0), 0);
// 총 예상 지출: cards[3]이 API total_amount (advance 등 미표시 항목 포함)
const totalAmount = Number(data.cards[3]?.amount) || 0;
return (
<CollapsibleDashboardCard
icon={<Receipt className="h-5 w-5 text-white" />}
title="당월 예상 지출 내역"
subtitle="이달 예상 지출 정보"
rightElement={
<Badge className="bg-orange-500 text-white border-none hover:opacity-90">
+15%
</Badge>
}
rightElement={undefined}
>
{/* 카드 그리드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
{/* 카드 1: 매입 */}
<div
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-purple-50 border-purple-200 dark:bg-purple-900/30 dark:border-purple-800"
onClick={() => onCardClick?.(data.cards[0]?.id || 'me1')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#8b5cf6' }} className="p-1.5 rounded-lg">
<Receipt className="h-4 w-4 text-white" />
{EXPENSE_CARD_CONFIGS.map((config, idx) => {
const card = data.cards[idx];
const CardIcon = config.icon;
return (
<div
key={config.defaultId}
className={`rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col ${config.bgClass}`}
onClick={() => onCardClick?.(card?.id || config.defaultId)}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: config.iconBg }} className="p-1.5 rounded-lg">
<CardIcon className="h-4 w-4 text-white" />
</div>
<span className={`text-sm font-medium ${config.labelClass}`}>
{card?.label || config.defaultLabel}
</span>
</div>
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(card?.amount || 0)}
</div>
{card?.previousLabel && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{card.previousLabel}
</div>
)}
</div>
<span className="text-sm font-medium text-purple-700 dark:text-purple-300">
{data.cards[0]?.label || '매입'}
</span>
</div>
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(data.cards[0]?.amount || 0)}
</div>
{data.cards[0]?.previousLabel && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{data.cards[0].previousLabel}
</div>
)}
</div>
{/* 카드 2: 카드 */}
<div
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-blue-50 border-blue-200 dark:bg-blue-900/30 dark:border-blue-800"
onClick={() => onCardClick?.(data.cards[1]?.id || 'me2')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#3b82f6' }} className="p-1.5 rounded-lg">
<CreditCard className="h-4 w-4 text-white" />
</div>
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
{data.cards[1]?.label || '카드'}
</span>
</div>
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(data.cards[1]?.amount || 0)}
</div>
{data.cards[1]?.previousLabel && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{data.cards[1].previousLabel}
</div>
)}
</div>
{/* 카드 3: 발행어음 */}
<div
className="rounded-xl p-4 cursor-pointer transition-all hover:scale-[1.02] hover:shadow-lg border min-h-[130px] flex flex-col bg-amber-50 border-amber-200 dark:bg-amber-900/30 dark:border-amber-800"
onClick={() => onCardClick?.(data.cards[2]?.id || 'me3')}
>
<div className="flex items-center gap-2 mb-2">
<div style={{ backgroundColor: '#f59e0b' }} className="p-1.5 rounded-lg">
<Banknote className="h-4 w-4 text-white" />
</div>
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">
{data.cards[2]?.label || '발행어음'}
</span>
</div>
<div className="text-2xl font-bold text-foreground">
{formatKoreanAmount(data.cards[2]?.amount || 0)}
</div>
{data.cards[2]?.previousLabel && (
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300">
<TrendingUp className="h-3 w-3" />
{data.cards[2].previousLabel}
</div>
)}
</div>
);
})}
{/* 카드 4: 총 예상 지출 합계 (강조) */}
<div
@@ -381,7 +356,7 @@ export function EnhancedMonthlyExpenseSection({ data, onCardClick }: EnhancedMon
</div>
<div className="flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold mt-auto w-fit bg-white/20 text-white">
<TrendingUp className="h-3 w-3" />
+10.5%
{data.cards[3]?.previousLabel || '전월 대비 0.0%'}
</div>
</div>
</div>

View File

@@ -1,12 +1,12 @@
'use client';
import { Wine, Utensils, Users, CreditCard } from 'lucide-react';
import { Moon, ShieldAlert, Banknote, FileWarning, Wine } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { EntertainmentData } from '../types';
// 카드별 아이콘 매핑
const CARD_ICONS = [Wine, Utensils, Users, CreditCard];
const CARD_THEMES: SectionColorTheme[] = ['pink', 'purple', 'indigo', 'red'];
// 카드별 아이콘 매핑 (주말/심야, 기피업종, 고액결제, 증빙미비)
const CARD_ICONS = [Moon, ShieldAlert, Banknote, FileWarning];
const CARD_THEMES: SectionColorTheme[] = ['purple', 'red', 'orange', 'pink'];
interface EntertainmentSectionProps {
data: EntertainmentData;

View File

@@ -10,13 +10,7 @@ import {
AlertCircle,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { formatCompactAmount } from '@/lib/utils/amount';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import {
BarChart,
@@ -39,24 +33,12 @@ interface PurchaseStatusSectionProps {
data: PurchaseStatusData;
}
const formatAmount = (value: number) => {
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}`;
if (value >= 10000) return `${(value / 10000).toFixed(0)}`;
return value.toLocaleString();
};
export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
const [supplierFilter, setSupplierFilter] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('date-desc');
const filteredItems = data.dailyItems
.filter((item) => supplierFilter.length === 0 || supplierFilter.includes(item.supplier))
.sort((a, b) => {
if (sortOrder === 'date-desc') return b.date.localeCompare(a.date);
if (sortOrder === 'date-asc') return a.date.localeCompare(b.date);
if (sortOrder === 'amount-desc') return b.amount - a.amount;
return a.amount - b.amount;
});
.filter((item) => supplierFilter.length === 0 || supplierFilter.includes(item.supplier));
const suppliers = [...new Set(data.dailyItems.map((item) => item.supplier))];
@@ -130,7 +112,7 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
<BarChart data={data.monthlyTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
<YAxis tickFormatter={formatCompactAmount} tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매입']}
/>
@@ -189,17 +171,6 @@ export function PurchaseStatusSection({ data }: PurchaseStatusSectionProps) {
placeholder="전체 공급처"
className="w-full h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[500px]">

View File

@@ -1,13 +1,13 @@
'use client';
import { useRouter } from 'next/navigation';
import { Banknote, Clock, AlertTriangle, CircleDollarSign, ChevronRight } from 'lucide-react';
import { Banknote, CircleDollarSign, Building2, TrendingUp, ChevronRight } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { ReceivableData } from '../types';
// 카드별 아이콘 매핑 (미수금 합계, 30일 이내, 30~90일, 90일 초과)
const CARD_ICONS = [CircleDollarSign, Banknote, Clock, AlertTriangle];
// 카드별 아이콘 매핑 (누적미수금, 당월미수금, 거래처, Top3)
const CARD_ICONS = [CircleDollarSign, Banknote, Building2, TrendingUp];
const CARD_THEMES: SectionColorTheme[] = ['amber', 'green', 'orange', 'red'];
interface ReceivableSectionProps {

View File

@@ -12,14 +12,8 @@ import {
DollarSign,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { formatCompactAmount } from '@/lib/utils/amount';
import {
BarChart,
Bar,
@@ -37,24 +31,12 @@ interface SalesStatusSectionProps {
data: SalesStatusData;
}
const formatAmount = (value: number) => {
if (value >= 100000000) return `${(value / 100000000).toFixed(1)}`;
if (value >= 10000) return `${(value / 10000).toFixed(0)}`;
return value.toLocaleString();
};
export function SalesStatusSection({ data }: SalesStatusSectionProps) {
const [clientFilter, setClientFilter] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('date-desc');
const filteredItems = data.dailyItems
.filter((item) => clientFilter.length === 0 || clientFilter.includes(item.client))
.sort((a, b) => {
if (sortOrder === 'date-desc') return b.date.localeCompare(a.date);
if (sortOrder === 'date-asc') return a.date.localeCompare(b.date);
if (sortOrder === 'amount-desc') return b.amount - a.amount;
return a.amount - b.amount;
});
.filter((item) => clientFilter.length === 0 || clientFilter.includes(item.client));
const clients = [...new Set(data.dailyItems.map((item) => item.client))];
@@ -143,7 +125,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
<BarChart data={data.monthlyTrend}>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis dataKey="month" tick={{ fontSize: 12 }} />
<YAxis tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
<YAxis tickFormatter={formatCompactAmount} tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']}
/>
@@ -158,7 +140,7 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
<ResponsiveContainer width="100%" height={200}>
<BarChart data={data.clientSales} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis type="number" tickFormatter={formatAmount} tick={{ fontSize: 11 }} />
<XAxis type="number" tickFormatter={formatCompactAmount} tick={{ fontSize: 11 }} />
<YAxis type="category" dataKey="name" tick={{ fontSize: 12 }} width={80} />
<Tooltip
formatter={(value) => [formatKoreanAmount(Number(value) || 0), '매출']}
@@ -187,17 +169,6 @@ export function SalesStatusSection({ data }: SalesStatusSectionProps) {
placeholder="전체 거래처"
className="w-full h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="date-desc"></SelectItem>
<SelectItem value="date-asc"></SelectItem>
<SelectItem value="amount-desc"> </SelectItem>
<SelectItem value="amount-asc"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[500px]">

View File

@@ -13,9 +13,9 @@ const LABEL_TO_SETTING_KEY: Record<string, keyof TodayIssueSettings> = {
'세금 신고': 'taxReport',
'신규 업체 등록': 'newVendor',
'연차': 'annualLeave',
'지각': 'lateness',
'결근': 'absence',
'발주': 'purchase',
'차량': 'vehicle',
'장비': 'equipment',
// '발주': 'purchase', // [2026-03-03] 비활성화 — transformer에서 필터링됨 (N4 참조)
'결재 요청': 'approvalRequest',
};
@@ -28,6 +28,7 @@ export function StatusBoardSection({ items, itemSettings }: StatusBoardSectionPr
const router = useRouter();
const handleItemClick = (path: string) => {
if (!path) return;
router.push(path);
};

View File

@@ -3,13 +3,6 @@
import { useState } from 'react';
import { PackageX } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox } from '@/components/ui/multi-select-combobox';
import { CollapsibleDashboardCard } from '../components';
import type { UnshippedData } from '../types';
@@ -20,16 +13,11 @@ interface UnshippedSectionProps {
export function UnshippedSection({ data }: UnshippedSectionProps) {
const [clientFilter, setClientFilter] = useState<string[]>([]);
const [sortOrder, setSortOrder] = useState('due-asc');
const clients = [...new Set(data.items.map((item) => item.orderClient))];
const filteredItems = data.items
.filter((item) => clientFilter.length === 0 || clientFilter.includes(item.orderClient))
.sort((a, b) => {
if (sortOrder === 'due-asc') return a.daysLeft - b.daysLeft;
return b.daysLeft - a.daysLeft;
});
.filter((item) => clientFilter.length === 0 || clientFilter.includes(item.orderClient));
return (
<CollapsibleDashboardCard
@@ -55,15 +43,6 @@ export function UnshippedSection({ data }: UnshippedSectionProps) {
placeholder="전체 거래처"
className="w-full h-8 text-xs"
/>
<Select value={sortOrder} onValueChange={setSortOrder}>
<SelectTrigger className="w-full h-8 text-xs">
<SelectValue placeholder="정렬" />
</SelectTrigger>
<SelectContent>
<SelectItem value="due-asc"> </SelectItem>
<SelectItem value="due-desc"> </SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[550px]">

View File

@@ -1,12 +1,12 @@
'use client';
import { Heart, Gift, Coffee, Smile } from 'lucide-react';
import { Receipt, Moon, UserX, BarChart3, Heart } from 'lucide-react';
import { AmountCardItem, CheckPointItem, CollapsibleDashboardCard, type SectionColorTheme } from '../components';
import type { WelfareData } from '../types';
// 카드별 아이콘 매핑
const CARD_ICONS = [Heart, Gift, Coffee, Smile];
const CARD_THEMES: SectionColorTheme[] = ['emerald', 'green', 'cyan', 'blue'];
// 카드별 아이콘 매핑 (비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과)
const CARD_ICONS = [Receipt, Moon, UserX, BarChart3];
const CARD_THEMES: SectionColorTheme[] = ['red', 'purple', 'orange', 'cyan'];
interface WelfareSectionProps {
data: WelfareData;

View File

@@ -326,19 +326,19 @@ export interface DailyAttendanceData {
}
// CEO Dashboard 전체 데이터
// 모든 필드 optional: mock 제거 후 API 미구현 섹션은 undefined
export interface CEODashboardData {
todayIssue: TodayIssueItem[]; // 현황판용 (구 오늘의 이슈)
todayIssueList: TodayIssueListItem[]; // 새 오늘의 이슈 (리스트 형태)
dailyReport: DailyReportData;
monthlyExpense: MonthlyExpenseData;
cardManagement: CardManagementData;
entertainment: EntertainmentData;
welfare: WelfareData;
receivable: ReceivableData;
debtCollection: DebtCollectionData;
vat: VatData;
calendarSchedules: CalendarScheduleItem[];
// 신규 섹션 (API 미구현 - mock 데이터)
dailyReport?: DailyReportData;
monthlyExpense?: MonthlyExpenseData;
cardManagement?: CardManagementData;
entertainment?: EntertainmentData;
welfare?: WelfareData;
receivable?: ReceivableData;
debtCollection?: DebtCollectionData;
vat?: VatData;
calendarSchedules?: CalendarScheduleItem[];
salesStatus?: SalesStatusData;
purchaseStatus?: PurchaseStatusData;
dailyProduction?: DailyProductionData;
@@ -396,7 +396,7 @@ export const SECTION_LABELS: Record<SectionKey, string> = {
dailyReport: '자금현황',
statusBoard: '현황판',
monthlyExpense: '당월 예상 지출 내역',
cardManagement: '카드/가지급금 관리',
cardManagement: '가지급금 현황',
entertainment: '접대비 현황',
welfare: '복리후생비 현황',
receivable: '미수금 현황',
@@ -422,10 +422,11 @@ export interface TodayIssueSettings {
taxReport: boolean; // 세금 신고
newVendor: boolean; // 신규 업체 등록
annualLeave: boolean; // 연차
lateness: boolean; // 지각
absence: boolean; // 결근
vehicle: boolean; // 차량
equipment: boolean; // 장비
purchase: boolean; // 발주
approvalRequest: boolean; // 결재 요청
fundStatus: boolean; // 자금 현황
}
// 접대비 한도 관리 타입
@@ -445,6 +446,7 @@ export interface EntertainmentSettings {
enabled: boolean;
limitType: EntertainmentLimitType;
companyType: CompanyType;
highAmountThreshold: number; // 고액 결제 기준 금액
}
// 복리후생비 설정
@@ -455,6 +457,7 @@ export interface WelfareSettings {
fixedAmountPerMonth: number; // 직원당 정해 금액/월
ratio: number; // 연봉 총액 X 비율 (%)
annualTotal: number; // 연간 복리후생비총액
singlePaymentThreshold: number; // 1회 결제 기준 금액
}
// 대시보드 전체 설정
@@ -662,10 +665,44 @@ export interface QuarterlyTableConfig {
rows: QuarterlyTableRow[];
}
// 검토 필요 카드 아이템 타입
export interface ReviewCardItem {
label: string;
amount: number;
subLabel: string; // e.g., "미증빙 5건"
}
// 검토 필요 카드 섹션 설정 타입
export interface ReviewCardsConfig {
title: string;
cards: ReviewCardItem[];
}
// 기간 필터 설정 타입
export type DateFilterPreset = '당해년도' | '전전월' | '전월' | '당월' | '어제' | '오늘';
export interface DateFilterConfig {
enabled: boolean;
presets?: DateFilterPreset[]; // 기간 버튼 목록 (기본: 전체)
defaultPreset?: DateFilterPreset; // 기본 선택 프리셋
showSearch?: boolean; // 검색 입력창 표시 여부
}
// 신고기간 셀렉트 설정 타입
export interface PeriodSelectConfig {
enabled: boolean;
options: { value: string; label: string }[];
defaultValue?: string;
onPeriodChange?: (value: string) => void;
}
// 상세 모달 전체 설정 타입
export interface DetailModalConfig {
title: string;
dateFilter?: DateFilterConfig; // 기간선택기 + 검색
periodSelect?: PeriodSelectConfig; // 신고기간 셀렉트 (부가세 등)
summaryCards: SummaryCardData[];
reviewCards?: ReviewCardsConfig; // 검토 필요 카드 섹션
barChart?: BarChartConfig;
pieChart?: PieChartConfig;
horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용)
@@ -691,10 +728,11 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
taxReport: false,
newVendor: false,
annualLeave: true,
lateness: true,
absence: false,
vehicle: false,
equipment: false,
purchase: false,
approvalRequest: false,
fundStatus: true,
},
},
dailyReport: true,
@@ -704,6 +742,7 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
enabled: true,
limitType: 'annual',
companyType: 'medium',
highAmountThreshold: 500000,
},
welfare: {
enabled: true,
@@ -712,6 +751,7 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
fixedAmountPerMonth: 200000,
ratio: 20.5,
annualTotal: 20000000,
singlePaymentThreshold: 500000,
},
receivable: true,
debtCollection: true,
@@ -737,10 +777,11 @@ export const DEFAULT_DASHBOARD_SETTINGS: DashboardSettings = {
taxReport: false,
newVendor: false,
annualLeave: true,
lateness: true,
absence: false,
vehicle: false,
equipment: false,
purchase: false,
approvalRequest: false,
fundStatus: true,
},
},
};

View File

@@ -0,0 +1,293 @@
'use client';
import { useMemo, useEffect, useState, useRef, useCallback } from 'react';
import type { CEODashboardData, DashboardSettings, SectionKey } from './types';
import { SECTION_LABELS } from './types';
export type SummaryStatus = 'normal' | 'warning' | 'danger';
export interface SectionSummary {
key: SectionKey;
label: string;
value: string;
status: SummaryStatus;
}
/** 숫자를 간략하게 포맷 (억/만) — 칩 표시용 (간결 + 반올림) */
function formatCompact(n: number): string {
if (n === 0) return '0원';
const abs = Math.abs(n);
const sign = n < 0 ? '-' : '';
if (abs >= 100_000_000) {
const v = Math.round(abs / 100_000_000 * 10) / 10;
return `${sign}${v % 1 === 0 ? v.toFixed(0) : v.toFixed(1)}`;
}
if (abs >= 10_000) {
const v = Math.round(abs / 10_000);
return `${sign}${v.toLocaleString()}`;
}
if (abs > 0) return `${sign}${abs.toLocaleString()}`;
return '0원';
}
/** 카드 배열에서 합계 카드(마지막) 금액 추출 — "합계" 라벨이 있으면 그것, 없으면 첫 번째 */
function getTotalCardAmount(cards?: { label: string; amount: number }[]): number {
if (!cards?.length) return 0;
const totalCard = cards.find((c) => c.label.includes('합계'));
return totalCard ? totalCard.amount : cards[0].amount;
}
/** 체크포인트 배열에서 가장 심각한 상태 추출 */
function checkPointStatus(
checkPoints?: { type: string }[],
): SummaryStatus {
if (!checkPoints?.length) return 'normal';
if (checkPoints.some((c) => c.type === 'error')) return 'danger';
if (checkPoints.some((c) => c.type === 'warning')) return 'warning';
return 'normal';
}
/** 섹션 활성화 여부 확인 */
function isSectionEnabled(key: SectionKey, settings: DashboardSettings): boolean {
switch (key) {
case 'todayIssueList': return !!settings.todayIssueList;
case 'dailyReport': return !!settings.dailyReport;
case 'statusBoard': return !!(settings.statusBoard?.enabled ?? settings.todayIssue.enabled);
case 'monthlyExpense': return !!settings.monthlyExpense;
case 'cardManagement': return !!settings.cardManagement;
case 'entertainment': return !!settings.entertainment.enabled;
case 'welfare': return !!settings.welfare.enabled;
case 'receivable': return !!settings.receivable;
case 'debtCollection': return !!settings.debtCollection;
case 'vat': return !!settings.vat;
case 'calendar': return !!settings.calendar;
case 'salesStatus': return !!(settings.salesStatus ?? true);
case 'purchaseStatus': return !!(settings.purchaseStatus ?? true);
case 'production': return !!(settings.production ?? true);
case 'shipment': return !!(settings.shipment ?? true);
case 'unshipped': return !!(settings.unshipped ?? true);
case 'construction': return !!(settings.construction ?? true);
case 'attendance': return !!(settings.attendance ?? true);
default: return false;
}
}
/** 섹션별 요약값 + 상태 추출 */
function extractSummary(
key: SectionKey,
data: CEODashboardData,
): { value: string; status: SummaryStatus } {
switch (key) {
case 'todayIssueList': {
const count = data.todayIssueList?.length ?? 0;
return { value: `${count}`, status: count > 0 ? 'warning' : 'normal' };
}
case 'dailyReport': {
const firstCard = data.dailyReport?.cards?.[0];
return {
value: firstCard ? formatCompact(firstCard.amount) : '-',
status: 'normal',
};
}
case 'statusBoard': {
const count = data.todayIssue?.length ?? 0;
const hasHighlight = data.todayIssue?.some((i) => i.isHighlighted);
return {
value: `${count}항목`,
status: hasHighlight ? 'danger' : 'normal',
};
}
case 'monthlyExpense': {
const total = getTotalCardAmount(data.monthlyExpense?.cards);
return {
value: formatCompact(total),
status: checkPointStatus(data.monthlyExpense?.checkPoints),
};
}
case 'cardManagement': {
const total = getTotalCardAmount(data.cardManagement?.cards);
const hasHighlight = data.cardManagement?.cards?.some((c) => c.isHighlighted);
const hasWarning = !!data.cardManagement?.warningBanner;
return {
value: formatCompact(total),
status: hasHighlight ? 'danger' : hasWarning ? 'warning' : 'normal',
};
}
case 'entertainment': {
const total = data.entertainment?.cards?.reduce((s, c) => s + c.amount, 0) ?? 0;
return {
value: formatCompact(total),
status: checkPointStatus(data.entertainment?.checkPoints),
};
}
case 'welfare': {
const total = data.welfare?.cards?.reduce((s, c) => s + c.amount, 0) ?? 0;
return {
value: formatCompact(total),
status: checkPointStatus(data.welfare?.checkPoints),
};
}
case 'receivable': {
// 누적 미수금 = 첫 번째 카드
const first = data.receivable?.cards?.[0];
return {
value: first ? formatCompact(first.amount) : '-',
status: checkPointStatus(data.receivable?.checkPoints),
};
}
case 'debtCollection': {
const first = data.debtCollection?.cards?.[0];
return {
value: first ? formatCompact(first.amount) : '-',
status: checkPointStatus(data.debtCollection?.checkPoints),
};
}
case 'vat': {
const first = data.vat?.cards?.[0];
return {
value: first ? formatCompact(first.amount) : '-',
status: 'normal',
};
}
case 'calendar': {
const count = data.calendarSchedules?.length ?? 0;
return { value: `${count}일정`, status: 'normal' };
}
case 'salesStatus': {
return {
value: formatCompact(data.salesStatus?.cumulativeSales ?? 0),
status: 'normal',
};
}
case 'purchaseStatus': {
return {
value: formatCompact(data.purchaseStatus?.cumulativePurchase ?? 0),
status: 'normal',
};
}
case 'production': {
const count = data.dailyProduction?.processes?.length ?? 0;
return { value: `${count}공정`, status: 'normal' };
}
case 'shipment': {
const count = data.dailyProduction?.shipment?.actualCount ?? 0;
return { value: `${count}`, status: 'normal' };
}
case 'unshipped': {
const count = data.unshipped?.items?.length ?? 0;
return {
value: `${count}`,
status: count > 0 ? 'danger' : 'normal',
};
}
case 'construction': {
return {
value: `${data.constructionData?.thisMonth ?? 0}`,
status: 'normal',
};
}
case 'attendance': {
return {
value: `${data.dailyAttendance?.present ?? 0}`,
status: 'normal',
};
}
default:
return { value: '-', status: 'normal' };
}
}
interface UseSectionSummaryParams {
data: CEODashboardData;
sectionOrder: SectionKey[];
dashboardSettings: DashboardSettings;
}
interface UseSectionSummaryReturn {
summaries: SectionSummary[];
activeSectionKey: SectionKey | null;
sectionRefs: React.MutableRefObject<Map<SectionKey, HTMLElement>>;
scrollToSection: (key: SectionKey) => void;
}
export function useSectionSummary({
data,
sectionOrder,
dashboardSettings,
}: UseSectionSummaryParams): UseSectionSummaryReturn {
const sectionRefs = useRef<Map<SectionKey, HTMLElement>>(new Map());
const [activeSectionKey, setActiveSectionKey] = useState<SectionKey | null>(null);
// 칩 클릭으로 선택된 키 — 해당 섹션이 화면에 보이는 한 유지
const pinnedKey = useRef<SectionKey | null>(null);
// 활성화된 섹션만 필터
const enabledSections = useMemo(
() => sectionOrder.filter((key) => isSectionEnabled(key, dashboardSettings)),
[sectionOrder, dashboardSettings],
);
// 요약 데이터 계산
const summaries = useMemo<SectionSummary[]>(
() =>
enabledSections.map((key) => {
const { value, status } = extractSummary(key, data);
return { key, label: SECTION_LABELS[key], value, status };
}),
[enabledSections, data],
);
// 스크롤 기반 현재 섹션 감지
useEffect(() => {
const handleScroll = () => {
// pin이 걸려 있으면 스크롤 감지 무시 (칩 클릭 후 programmatic scroll 중)
if (pinnedKey.current) return;
const headerBottom = 156; // 헤더(~100px) + 요약바(~56px)
let bestKey: SectionKey | null = null;
let bestDistance = Infinity;
for (const [key, el] of sectionRefs.current.entries()) {
const rect = el.getBoundingClientRect();
const distance = Math.abs(rect.top - headerBottom);
if (rect.top < window.innerHeight * 0.6 && rect.bottom > headerBottom) {
if (distance < bestDistance) {
bestDistance = distance;
bestKey = key;
}
}
}
if (bestKey) {
setActiveSectionKey(bestKey);
}
};
// 사용자가 직접 스크롤(마우스 휠/터치)하면 pin 해제
const handleUserScroll = () => { pinnedKey.current = null; };
window.addEventListener('scroll', handleScroll, { passive: true });
window.addEventListener('wheel', handleUserScroll, { passive: true });
window.addEventListener('touchstart', handleUserScroll, { passive: true });
handleScroll(); // 초기 호출
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('wheel', handleUserScroll);
window.removeEventListener('touchstart', handleUserScroll);
};
}, [enabledSections, summaries]);
// 칩 클릭 → 즉시 활성 표시 + 섹션으로 스크롤
const scrollToSection = useCallback((key: SectionKey) => {
setActiveSectionKey(key);
pinnedKey.current = key; // 해당 섹션이 화면에 보이는 한 유지
const el = sectionRefs.current.get(key);
if (!el) return;
const elRect = el.getBoundingClientRect();
const offset = window.scrollY + elRect.top - 160; // 헤더(~100) + 요약바(~56) + 여유
window.scrollTo({ top: offset, behavior: 'smooth' });
}, []);
return { summaries, activeSectionKey, sectionRefs, scrollToSection };
}

View File

@@ -198,7 +198,8 @@ export function DocumentViewer({
}
}
} catch {
// 변환 실패 시 원본 src 유지
// 변환 실패 시 빈 이미지로 대체 (Puppeteer에서 proxy URL 요청 방지)
clonedImg.setAttribute('src', 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7');
}
}
})

View File

@@ -681,9 +681,9 @@ export function VacationManagement() {
columns: tableColumns,
// 공통 패턴: dateRangeSelector
// 신청현황 탭에서만 날짜 필터 표시 (사용현황/부여현황은 연간 데이터)
dateRangeSelector: {
enabled: true,
enabled: mainTab === 'request',
startDate,
endDate,
onStartDateChange: setStartDate,

View File

@@ -1,8 +1,8 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Star } from 'lucide-react';
import { Bookmark, MoreHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Tooltip,
@@ -20,14 +20,68 @@ import { useFavoritesStore } from '@/stores/favoritesStore';
import { iconMap } from '@/lib/utils/menuTransform';
import type { FavoriteItem } from '@/stores/favoritesStore';
// "시스템 대시보드" 기준 텍스트 폭 (7글자 ≈ 80px)
const TEXT_DEFAULT_MAX = 80;
const TEXT_EXPANDED_MAX = 200;
const TEXT_SHRUNK_MAX = 28;
const OVERFLOW_BTN_WIDTH = 56;
const GAP = 6;
interface HeaderFavoritesBarProps {
isMobile: boolean;
}
/** 별 아이콘 드롭다운 (공간 부족 / 모바일 / 태블릿) */
function StarDropdown({
favorites,
className,
onItemClick,
}: {
favorites: FavoriteItem[];
className?: string;
onItemClick: (item: FavoriteItem) => void;
}) {
const getIcon = (name: string) => iconMap[name] || null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
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" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{favorites.map((item) => {
const Icon = getIcon(item.iconName);
return (
<DropdownMenuItem
key={item.id}
onClick={() => onItemClick(item)}
className="flex items-center gap-2 cursor-pointer"
>
{Icon && <Icon className="h-4 w-4" />}
<span>{item.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps) {
const router = useRouter();
const { favorites } = useFavoritesStore();
const [isTablet, setIsTablet] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const chipWidthsRef = useRef<number[]>([]);
const measuredRef = useRef(false);
const [visibleCount, setVisibleCount] = useState(favorites.length);
const [hoveredId, setHoveredId] = useState<string | null>(null);
// 태블릿 감지 (768~1024)
useEffect(() => {
@@ -40,6 +94,70 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps
return () => window.removeEventListener('resize', check);
}, []);
// 즐겨찾기 변경 시 측정 리셋
useEffect(() => {
measuredRef.current = false;
chipWidthsRef.current = [];
setVisibleCount(favorites.length);
}, [favorites.length]);
// 모바일/태블릿 ↔ 데스크탑 전환 시 측정 리셋
useEffect(() => {
if (!isMobile && !isTablet) {
measuredRef.current = false;
chipWidthsRef.current = [];
setVisibleCount(favorites.length);
}
}, [isMobile, isTablet, favorites.length]);
// 데스크탑 동적 오버플로: 전체 chip 폭 측정 → 저장 → resize 시 재계산
useEffect(() => {
if (isMobile || isTablet) return;
const container = containerRef.current;
if (!container) return;
const calculate = () => {
// 최초: 전체 chip 렌더 상태에서 폭 저장
if (!measuredRef.current) {
const chips = container.querySelectorAll<HTMLElement>('[data-chip]');
if (chips.length === favorites.length && chips.length > 0) {
chipWidthsRef.current = Array.from(chips).map((c) => c.offsetWidth);
measuredRef.current = true;
} else {
return;
}
}
const containerWidth = container.offsetWidth;
const widths = chipWidthsRef.current;
// 공간 부족: chip 1개 + overflow 버튼도 안 들어가면 전부 드롭다운
const minChipWidth = Math.min(...widths);
if (containerWidth < minChipWidth + OVERFLOW_BTN_WIDTH + GAP) {
setVisibleCount(0);
return;
}
let totalWidth = 0;
let count = 0;
for (let i = 0; i < widths.length; i++) {
const needed = totalWidth + widths[i] + (count > 0 ? GAP : 0);
const hasMore = i < widths.length - 1;
const reserve = hasMore ? OVERFLOW_BTN_WIDTH + GAP : 0;
if (needed + reserve > containerWidth && count > 0) break;
totalWidth = needed;
count++;
}
setVisibleCount(count);
};
requestAnimationFrame(calculate);
const observer = new ResizeObserver(calculate);
observer.observe(container);
return () => observer.disconnect();
}, [isMobile, isTablet, favorites.length]);
const handleClick = useCallback(
(item: FavoriteItem) => {
router.push(item.path);
@@ -49,106 +167,121 @@ export default function HeaderFavoritesBar({ isMobile }: HeaderFavoritesBarProps
if (favorites.length === 0) return null;
const getIcon = (iconName: string) => {
return iconMap[iconName] || null;
};
const getIcon = (iconName: string) => iconMap[iconName] || null;
// 모바일 & 태블릿: 별 아이콘 드롭다운
if (isMobile || isTablet) {
// 모바일: 별 아이콘 드롭다운 (모바일 헤더용 - flex-1 불필요)
if (isMobile) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className={`p-0 rounded-lg min-[320px]:rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center ${
isMobile
? 'min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px]'
: 'w-10 h-10'
}`}
title="즐겨찾기"
>
<Star className="h-4 w-4 fill-white" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{favorites.map((item) => {
const Icon = getIcon(item.iconName);
return (
<DropdownMenuItem
key={item.id}
onClick={() => handleClick(item)}
className="flex items-center gap-2 cursor-pointer"
>
{Icon && <Icon className="h-4 w-4" />}
<span>{item.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<StarDropdown
favorites={favorites}
onItemClick={handleClick}
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] rounded-lg min-[320px]:rounded-xl"
/>
);
}
// 데스크톱: 8개 이하 → 아이콘 버튼, 9개 이상 → 별 드롭다운
const DESKTOP_ICON_LIMIT = 8;
if (favorites.length > DESKTOP_ICON_LIMIT) {
// 태블릿: 별 드롭다운 + flex-1 wrapper (데스크탑 헤더에서 오른쪽 정렬 유지)
if (isTablet) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className="w-10 h-10 p-0 rounded-xl bg-blue-600 hover:bg-blue-700 text-white flex items-center justify-center"
title="즐겨찾기"
>
<Star className="h-4 w-4 fill-white" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{favorites.map((item) => {
const Icon = getIcon(item.iconName);
return (
<DropdownMenuItem
key={item.id}
onClick={() => handleClick(item)}
className="flex items-center gap-2 cursor-pointer"
>
{Icon && <Icon className="h-4 w-4" />}
<span>{item.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
<div className="flex-1 min-w-0 flex items-center justify-end">
<StarDropdown favorites={favorites} onItemClick={handleClick} className="w-10 h-10" />
</div>
);
}
// 데스크톱: containerRef를 항상 렌더 (ResizeObserver 안정성)
const visibleItems = favorites.slice(0, visibleCount);
const overflowItems = favorites.slice(visibleCount);
const showStarOnly = measuredRef.current && visibleCount === 0;
return (
<TooltipProvider delayDuration={300}>
<div className="flex items-center gap-2">
{favorites.map((item) => {
const Icon = getIcon(item.iconName);
if (!Icon) return null;
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<Button
variant="default"
size="sm"
onClick={() => handleClick(item)}
className="rounded-xl bg-blue-600 hover:bg-blue-700 text-white w-10 h-10 p-0 flex items-center justify-center transition-all duration-200"
>
<Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
);
})}
<div
ref={containerRef}
className="flex-1 min-w-0 flex items-center justify-end gap-1.5"
onMouseLeave={() => setHoveredId(null)}
>
{showStarOnly ? (
<StarDropdown favorites={favorites} onItemClick={handleClick} />
) : (
<>
{visibleItems.map((item) => {
const Icon = getIcon(item.iconName);
const isHovered = hoveredId === item.id;
const isOtherHovered = hoveredId !== null && !isHovered;
const textMaxWidth = isHovered
? TEXT_EXPANDED_MAX
: isOtherHovered
? TEXT_SHRUNK_MAX
: TEXT_DEFAULT_MAX;
return (
<Tooltip key={item.id}>
<TooltipTrigger asChild>
<Button
data-chip
variant="default"
size="sm"
onClick={() => handleClick(item)}
onMouseEnter={() => setHoveredId(item.id)}
className={`rounded-full text-white h-8 flex items-center overflow-hidden ${
isOtherHovered ? 'px-2 gap-1 bg-blue-400/70' : 'px-3 gap-1.5 bg-blue-600 hover:bg-blue-700'
}`}
style={{
transition: 'all 500ms cubic-bezier(0.25, 0.8, 0.25, 1)',
}}
>
{Icon && <Icon className="h-3.5 w-3.5 shrink-0" />}
<span
className="text-xs whitespace-nowrap overflow-hidden text-ellipsis"
style={{
maxWidth: textMaxWidth,
transition: 'max-width 500ms cubic-bezier(0.25, 0.8, 0.25, 1), opacity 400ms ease',
opacity: isOtherHovered ? 0.7 : 1,
}}
>
{item.label}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
);
})}
{overflowItems.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="default"
size="sm"
className="rounded-full bg-blue-500/80 hover:bg-blue-600 text-white h-8 px-2.5 gap-1 flex items-center shrink-0"
>
<MoreHorizontal className="h-3.5 w-3.5" />
<span className="text-xs">+{overflowItems.length}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
{overflowItems.map((item) => {
const Icon = getIcon(item.iconName);
return (
<DropdownMenuItem
key={item.id}
onClick={() => handleClick(item)}
className="flex items-center gap-2 cursor-pointer"
>
{Icon && <Icon className="h-4 w-4" />}
<span>{item.label}</span>
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)}
</div>
</TooltipProvider>
);

View File

@@ -1,4 +1,4 @@
import { ChevronRight, ChevronsDownUp, ChevronsUpDown, Circle, Star } from 'lucide-react';
import { Bookmark, 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';
@@ -153,11 +153,13 @@ function MenuItemComponent({
className={`flex-shrink-0 p-1 rounded transition-all duration-200 ${
isFav
? 'opacity-100 text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
: isMobile
? 'opacity-50 text-muted-foreground active:text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
<Bookmark className={`h-3.5 w-3.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</div>
@@ -216,11 +218,13 @@ function MenuItemComponent({
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
isFav
? 'opacity-100 text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
: isMobile
? 'opacity-50 text-muted-foreground active:text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
<Bookmark className={`h-3 w-3 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</div>
@@ -281,11 +285,13 @@ function MenuItemComponent({
className={`flex-shrink-0 p-0.5 rounded transition-all duration-200 ${
isFav
? 'opacity-100 text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
: isMobile
? 'opacity-50 text-muted-foreground active:text-yellow-500'
: 'opacity-0 group-hover/row:opacity-100 text-muted-foreground hover:text-yellow-500'
}`}
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
>
<Star className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
<Bookmark className={`h-2.5 w-2.5 ${isFav ? 'fill-yellow-500' : ''}`} />
</button>
)}
</div>

View File

@@ -35,6 +35,9 @@ import {
type InspectionTemplateResponse,
type DocumentResolveResponse,
} from './actions';
import { captureRenderedHtml } from '@/lib/utils/capture-rendered-html';
import { ImportInspectionDocument } from '@/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument';
import type { ImportInspectionTemplate, InspectionItemValue } from '@/app/[locale]/(protected)/quality/qms/components/documents/ImportInspectionDocument';
// ===== Props =====
interface ImportInspectionInputModalProps {
@@ -636,7 +639,37 @@ export function ImportInspectionInputModal({
})),
];
// 4. 저장 API 호출
// 4. 성적서 문서를 오프스크린 렌더링하여 HTML 스냅샷 캡처 (MNG 출력용)
let renderedHtml: string | undefined;
try {
// 현재 입력값을 ImportInspectionDocument의 initialValues 형식으로 변환
const docValues: InspectionItemValue[] = template.inspectionItems
.filter(i => i.isFirstInItem !== false)
.map(item => ({
itemId: item.id,
measurements: Array.from({ length: item.measurementCount }, (_, n) => {
if (item.measurementType === 'okng') {
const v = okngValues[item.id]?.[n];
return v === 'ok' ? ('OK' as const) : v === 'ng' ? ('NG' as const) : null;
}
const v = measurements[item.id]?.[n];
return v ? Number(v) : null;
}),
result: getItemResult(item) === 'ok' ? ('OK' as const) : getItemResult(item) === 'ng' ? ('NG' as const) : null,
}));
// 성적서 문서 컴포넌트를 오프스크린에서 렌더링
renderedHtml = captureRenderedHtml(
<ImportInspectionDocument
template={template as unknown as ImportInspectionTemplate}
initialValues={docValues}
readOnly
/>
);
} catch {
// 캡처 실패 시 무시 — rendered_html 없이 저장 진행
}
// 5. 저장 API 호출
const result = await saveInspectionData({
templateId: parseInt(template.templateId),
itemId,
@@ -645,6 +678,7 @@ export function ImportInspectionInputModal({
attachments,
receivingId,
inspectionResult: overallResult,
rendered_html: renderedHtml,
});
if (result.success) {

View File

@@ -12,12 +12,12 @@
import { useState, useCallback, useMemo, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { getTodayString } from '@/lib/utils/date';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { materialInspectionCreateConfig } from './inspectionConfig';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
@@ -29,7 +29,6 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { getReceivings } from './actions';
import type { InspectionCheckItem, ReceivingItem } from './types';
import { SuccessDialog } from './SuccessDialog';
@@ -81,7 +80,7 @@ export function InspectionCreate({ id }: Props) {
const [opinion, setOpinion] = useState('');
// 유효성 검사 에러
const [validationErrors, setValidationErrors] = useState<string[]>([]);
const [validationErrors, setValidationErrors] = useState<Record<string, string>>({});
// 성공 다이얼로그
const [showSuccess, setShowSuccess] = useState(false);
@@ -117,15 +116,22 @@ export function InspectionCreate({ id }: Props) {
// 대상 선택 핸들러
const handleTargetSelect = useCallback((targetId: string) => {
setSelectedTargetId(targetId);
setValidationErrors([]);
}, []);
// 판정 변경 핸들러
const handleJudgmentChange = useCallback((itemId: string, judgment: '적' | '부적') => {
const handleJudgmentChange = useCallback((itemId: string, index: number, judgment: '적' | '부적') => {
setInspectionItems((prev) =>
prev.map((item) => (item.id === itemId ? { ...item, judgment } : item))
);
setValidationErrors([]);
// 해당 항목의 에러 클리어
setValidationErrors((prev) => {
const key = `judgment_${index}`;
if (prev[key]) {
const { [key]: _, ...rest } = prev;
return rest;
}
return prev;
});
}, []);
// 비고 변경 핸들러
@@ -137,22 +143,29 @@ export function InspectionCreate({ id }: Props) {
// 유효성 검사
const validateForm = useCallback((): boolean => {
const errors: string[] = [];
const errors: Record<string, string> = {};
// 필수 필드: 검사자
if (!inspector.trim()) {
errors.push('검사자는 필수 입력 항목입니다.');
errors.inspector = '검사자는 필수 입력 항목입니다.';
}
// 검사 항목 판정 확인
inspectionItems.forEach((item, index) => {
if (!item.judgment) {
errors.push(`${index + 1}. ${item.name}: 판정을 선택해주세요.`);
errors[`judgment_${index}`] = `${item.name}: 판정을 선택해주세요.`;
}
});
setValidationErrors(errors);
return errors.length === 0;
if (Object.keys(errors).length > 0) {
const firstError = Object.values(errors)[0];
toast.error(firstError);
return false;
}
return true;
}, [inspector, inspectionItems]);
// 검사 저장
@@ -214,30 +227,6 @@ export function InspectionCreate({ id }: Props) {
{/* 우측: 검사 정보 및 항목 */}
<div className="lg:col-span-3 space-y-6">
{/* Validation 에러 표시 */}
{validationErrors.length > 0 && (
<Alert className="bg-red-50 border-red-200">
<AlertDescription className="text-red-900">
<div className="flex items-start gap-2">
<span className="text-lg"></span>
<div className="flex-1">
<strong className="block mb-2">
({validationErrors.length} )
</strong>
<ul className="space-y-1 text-sm">
{validationErrors.map((error, index) => (
<li key={index} className="flex items-start gap-1">
<span></span>
<span>{error}</span>
</li>
))}
</ul>
</div>
</div>
</AlertDescription>
</Alert>
)}
{/* 검사 정보 */}
<div className="space-y-4 bg-white p-4 rounded-lg border">
<h3 className="font-medium"> </h3>
@@ -257,10 +246,19 @@ export function InspectionCreate({ id }: Props) {
value={inspector}
onChange={(e) => {
setInspector(e.target.value);
setValidationErrors([]);
if (validationErrors.inspector) {
setValidationErrors((prev) => {
const { inspector: _, ...rest } = prev;
return rest;
});
}
}}
placeholder="검사자명 입력"
className={validationErrors.inspector ? 'border-red-500' : ''}
/>
{validationErrors.inspector && (
<p className="text-sm text-red-500">{validationErrors.inspector}</p>
)}
</div>
<div className="space-y-2">
<Label className="text-sm text-muted-foreground">LOT번호 (YYMMDD-##)</Label>
@@ -284,39 +282,45 @@ export function InspectionCreate({ id }: Props) {
</tr>
</thead>
<tbody>
{inspectionItems.map((item) => (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.name}</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{item.specification}
</td>
<td className="px-3 py-2">{item.method}</td>
<td className="px-3 py-2">
<Select
value={item.judgment || ''}
onValueChange={(value) =>
handleJudgmentChange(item.id, value as '적' | '부적')
}
>
<SelectTrigger className="h-8">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="적"></SelectItem>
<SelectItem value="부적"></SelectItem>
</SelectContent>
</Select>
</td>
<td className="px-3 py-2">
<Input
value={item.remark || ''}
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
placeholder="비고"
className="h-8"
/>
</td>
</tr>
))}
{inspectionItems.map((item, index) => {
const judgmentErrorKey = `judgment_${index}`;
return (
<tr key={item.id} className="border-t">
<td className="px-3 py-2">{item.name}</td>
<td className="px-3 py-2 text-muted-foreground text-xs">
{item.specification}
</td>
<td className="px-3 py-2">{item.method}</td>
<td className="px-3 py-2">
<Select
value={item.judgment || ''}
onValueChange={(value) =>
handleJudgmentChange(item.id, index, value as '적' | '부적')
}
>
<SelectTrigger className={`h-8 ${validationErrors[judgmentErrorKey] ? 'border-red-500' : ''}`}>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="적"></SelectItem>
<SelectItem value="부적"></SelectItem>
</SelectContent>
</Select>
{validationErrors[judgmentErrorKey] && (
<p className="text-xs text-red-500 mt-1">{validationErrors[judgmentErrorKey]}</p>
)}
</td>
<td className="px-3 py-2">
<Input
value={item.remark || ''}
onChange={(e) => handleRemarkChange(item.id, e.target.value)}
placeholder="비고"
className="h-8"
/>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -361,4 +365,4 @@ export function InspectionCreate({ id }: Props) {
renderForm={renderFormContent}
/>
);
}
}

Some files were not shown because too many files have changed in this diff Show More