86 Commits

Author SHA1 Message Date
김보곤
897d44603b fix: [auth] 회원가입 무료 체험 기간 30일 → 7일로 변경 2026-03-16 21:27:41 +09:00
유병철
9b6c84c4c8 fix: [ui] date-picker, date-range-picker, searchable-select, multi-select-combobox, time-picker 개선 2026-03-16 17:45:23 +09:00
유병철
e346aa0a02 chore: [ui] date-picker, searchable-select 불필요 코드 제거 2026-03-16 17:29:38 +09:00
유병철
0029988e6f feat: [approval] 전자결재 모듈 대폭 개선 + 회계 리팩토링
- 전자결재: 다양식 지원(11종), 완료함, 동적폼 렌더러, QA 보고서
- 회계: 계정과목 검색모달 리팩토링, 거래처/세금계산서 개선
- HR: 근태/휴가/직원 소소한 수정
- vehicle/quality/pricing 마이너 수정
- approval_backup_v1 백업 보관
2026-03-16 17:06:02 +09:00
1280c8d61a feat: [quotes] 견적 등록 개선
- 수주처 선택 시 담당자/연락처 자동 입력
- 현장명 직접 입력 가능 (creatable 옵션)
- SearchableSelect에 creatable 기능 추가
2026-03-14 08:29:30 +09:00
22a398024c fix: [inspection] 검사 문서 이미지 URL 생성 file_id 기반으로 변경
- getImageUrl 파라미터 순서 변경 (file_id 우선)
- 레거시 tenant path 직접 접근 제거, R2 프록시 사용
- SectionImage에 file_id prop 추가
2026-03-14 08:29:20 +09:00
31157122ca fix: [vendor] 거래처 관리 날짜 필터 기본값 변경
- 기본값을 당월 → 빈 값(전체 조회)으로 변경
- date-fns import 제거
- 날짜 필터 범위 조건 개선
2026-03-14 08:29:09 +09:00
ac3db01859 fix: [orders] revertProductionOrder 응답 타입 수정
- deletedCounts optional로 변경 (cancel 모드에서 미존재)
- cancelledCount, skippedCount 필드 추가 (운영 취소 모드 대응)
- RevertResponse 인터페이스 force/cancel 모드별 필드 분리
2026-03-14 08:29:03 +09:00
156a50fd73 fix: [build] 타입 오류 수정 (cancelledCount, lot_no)
- order-management-sales: revertProduction 결과 구조분해로 타입 추론 수정
- WorkerScreen/actions: lot_no 접근 타입 캐스트 추가
2026-03-14 08:28:11 +09:00
b87b94860b fix: [worker-screen] 프론트 가짜 LOT 제거, 백엔드 실제 LOT 사용
- actions.ts: 하드코딩 LOT(-01) 제거 → API 응답 lot_no 사용
- index.tsx: API 호출 후 실제 LOT으로 완료 다이얼로그 표시하도록 흐름 변경
2026-03-13 23:46:55 +09:00
c210ec1b5f feat: [shipment] 출하 상태변경 버튼에 can_ship 검증 UI 추가
- canShip=true일 때만 상태 변경 버튼 활성화
- canShip=false일 때 '출하 불가 (품질 검수 필요)' 비활성 버튼 표시
2026-03-13 22:46:18 +09:00
6bbc5867fe feat: [order] 수주 확정 모달에 품목 정보 테이블 추가
- 총금액 표시 제거
- 수주 품목 테이블 추가 (품목명, 층, 부호, 사이즈, 수량)
- nodes 기반 렌더링 우선, products fallback
- 모달 너비 max-w-md → max-w-lg 확장
2026-03-13 22:26:19 +09:00
유병철
c309ac479f feat: [vehicle] 법인차량 관리 모듈 + MES 분석 보고서 + 프론트엔드 문서
- 법인차량 관리 3개 페이지 (차량등록, 운행일지, 정비이력)
- MES 데이터 정합성 분석 보고서 v1/v2
- sam-docs 프론트엔드 기술문서 v1 (9개 챕터)
- claudedocs 가이드/테스트URL 업데이트
2026-03-13 17:52:57 +09:00
80164f722e fix: [build] 타입 오류 수정 (DepartmentDialog, PricingFormClient)
- PricingFormClient: mode prop에 'view' 타입 추가
- DepartmentDialog: Zod 스키마 .default() 제거 및 z.coerce.number→z.number 변경
2026-03-13 10:32:49 +09:00
742c0ba03e feat: [qms] 작업일지/제품검사 독립 모달 컴포넌트
- WorkLogModal: workOrderId로 공정별 작업일지 표시
- ProductInspectionViewModal: locationId로 FQC/레거시 검사 성적서 표시
- QMS 등 외부에서 재사용 가능한 독립 구조
2026-03-13 10:14:45 +09:00
8d33fafb48 fix: [storage] R2 테넌트 파일 경로 URL 변환 추가
- tenant path 패턴(숫자/) 감지하여 API URL 프리픽스 추가
- /storage/tenants/{path} R2 프록시 라우트와 연동
2026-03-13 10:14:37 +09:00
613d0c1069 fix: [work-order] 기타 탭 제거
- TAB_OTHER 상수 및 관련 로직 삭제
- 보조공정/미배정 작업은 API에서 필터링
2026-03-13 10:14:30 +09:00
13249384e2 feat: [부서관리] 기능 보완 - 필드 확장, 검색/필터, UI 개선
- Department 타입에 code, description, isActive, sortOrder 필드 추가
- DepartmentDialog: Zod + react-hook-form 폼 검증 (5개 필드)
- DepartmentToolbar: 상태 필터(전체/활성/비활성) + 검색 기능
- DepartmentTree: 트리 필터링 (검색어 + 상태)
- DepartmentTreeItem: 코드 Badge, 부서명 볼드, 설명 표시, 체크박스 크기 조정
- convertApiToLocal에서 누락 필드 매핑 복원
2026-03-13 00:30:09 +09:00
유병철
ca5a9325c6 feat: 급여관리 개선 + 설비관리 신규 + 팝업관리/카드관리/가격표 개선
- 급여관리: 상세/등록 다이얼로그 리팩토링, actions/types 확장
- 설비관리: 설비현황/점검/수리 4개 페이지 신규 추가
- 팝업관리: PopupDetail/PopupForm 개선
- 카드관리: CardForm 개선
- IntegratedListTemplateV2, SearchFilter, useColumnSettings 개선
- CLAUDE.md: 페이지 모드 라우팅 패턴 규칙 추가
- 공통 페이지 패턴 가이드 확장
2026-03-12 21:48:37 +09:00
945a371cdf sync: main 배포 동기화 2026-03-12 2026-03-12 15:23:13 +09:00
f7be78b6c5 fix: [qms] 빌드 타입 에러 수정
- Day1DocumentSection: onUpload prop optional 처리 및 guard 추가
- mockData: RouteItem 타입 필수 필드 client 누락 보완
2026-03-12 14:13:44 +09:00
bb1e4a25a1 fix: [QMS] 로트심사 UI 개선
- 수주루트 → 수주로트 명칭 통일
- 거래처(client) 필드 추가 (types, actions, RouteList)
- 문서번호 표시 개선 (로트: 접두어 제거)
- ReportList 레이아웃 개선 (분기 표시 위치)
- PlaceholderDocument 문서번호 라벨 수정
2026-03-12 14:01:13 +09:00
86383719ec fix: [QMS] 제품검사 성적서 렌더링 개선 (FQC + inspection_data fallback)
- InspectionModal: FQC 문서 없을 때 inspection_data JSON으로 레거시 리포트 렌더링
- InspectionReportDocument 컴포넌트 재활용 (기존 검사 페이지와 동일 포맷)
- mockData: convertJudgment, mapInspectionDataToItems export 추가
2026-03-12 11:16:40 +09:00
b7f7aad2fd feat: [생산/출하] 작업자 화면 step 서버 토글 + 출하 수주 조인 연동
- WorkerScreen: stepProgressId 있는 모든 step을 서버 토글 API 호출하도록 변경
  (기존: click_complete 타입만 서버 호출, 나머지 로컬 토글)
- ShipmentManagement actions: order_info에서 receiver/receiver_contact 우선 참조
  - OrderInfoApiData 타입 확장 (receiver, receiver_contact, delivery_address_detail, delivery_method)
  - 목록/상세 모두 수주 조인 데이터 우선, 출하 직접 필드 fallback
2026-03-12 11:16:40 +09:00
92b5a4a097 fix: [품질검사] LegacyPhotoUpload images undefined 에러 수정
images prop에 기본값 [] 추가하여 initialData에 productImages가 없을 때 TypeError 방지
2026-03-12 11:16:40 +09:00
7447e8a204 feat: [QMS] 점검표 템플릿 Mock→API 연동 + 버전 UI 제거
- actions.ts: 5개 Server Actions 추가 (조회/저장/문서CRUD)
- useChecklistTemplate: Mock→API 전환, loading/error 상태 추가
- ChecklistTemplateEditor: VersionSelectBox 제거, loading/error UI
- AuditSettingsPanel/page.tsx: 버전 관련 props 정리
- types.ts: ChecklistTemplateVersion 제거, ChecklistTemplate 수정
2026-03-12 11:16:40 +09:00
2692865b55 feat: [견적] 제어기 타입 변경 + 가이드레일 제품연동 + 수식보기 개선
- 제어기: 노출형/매립형(뒷박스포함)/매립형(뒷박스제외) 3가지로 변경
- 가이드레일: 제품코드 specification에서 벽면형/측면형/혼합형 자동 연동, Select 비활성
- FormulaViewModal: JSON 데이터를 범용 렌더러(GenericDataView)로 표시
- DevFill: 새 제어기 타입 + 제품 기반 가이드레일 적용
2026-03-12 11:16:40 +09:00
b768ac63c2 feat: [배포] Jenkinsfile 롤백 기능 추가
- parameters 블록 추가 (ACTION, ROLLBACK_TARGET, ROLLBACK_RELEASE)
- Jenkins 웹에서 Build with Parameters로 롤백 실행 가능
- 릴리스 목록 조회 + symlink 전환 + pm2 reload
- production/stage 환경 선택 가능
- 서버 IP를 PROD_SERVER 환경변수로 추출
- 롤백 시 Slack 알림 추가
2026-03-12 11:16:39 +09:00
유병철
ea6ca335f1 feat: CSP 다음/카카오 도메인 허용 + 입고 성적서 파일 백엔드 연동 + 팝업 이미지 중앙정렬
- middleware CSP: *.kakao.com, *.kakaocdn.net 추가 (다음 주소찾기 차단 해결)
- frame-src에 'self' 추가
- 공지 팝업 이미지 중앙정렬 ([&_img]:mx-auto)
- HR 사원관리, 결재, 품목, 생산 등 다수 개선
- API 에러 핸들링 및 JSON 파싱 안정화
2026-03-11 22:32:58 +09:00
유병철
e9ac2470e1 feat: QMS 체크리스트 템플릿 관리 및 견적/리스트 개선
- QMS 체크리스트 템플릿 에디터 추가 (ChecklistTemplateEditor)
- AuditSettingsPanel, Day1DocumentSection 기능 확장
- 견적 등록(QuoteRegistration) 개선
- IntegratedListTemplateV2 수정
- 건설 카테고리 actions 수정
2026-03-11 11:06:10 +09:00
유병철
81affdc441 feat: ESLint 정리 및 전체 코드 품질 개선
- eslint.config.mjs 규칙 강화 및 정리
- 전역 unused import/변수 제거 (312개 파일)
- next.config.ts, middleware, proxy route 개선
- CopyableCell molecule 추가
- 회계/결재/HR/생산/건설/품질/영업 등 전 도메인 lint 정리
- IntegratedListTemplateV2, DataTable, MobileCard 등 공통 컴포넌트 개선
- execute-server-action 에러 핸들링 보강
2026-03-11 10:27:10 +09:00
924726cba1 fix: middleware publicRoutes 타입 에러 수정 2026-03-11 02:15:06 +09:00
5b5a6bdf88 sync: main 배포 동기화 2026-03-11 2026-03-11 02:07:04 +09:00
44a82a7ed4 refactor: [작업자화면] 목업 데이터 제거, API 데이터만 표시
- MOCK_ITEMS, MOCK_SIDEBAR_ORDERS 등 목업 데이터 정의 삭제 (~200줄)
- 사이드바/작업목록에서 목업 병합 로직 제거
- 데이터 없을 때 빈 상태 메시지 표시
2026-03-11 01:11:26 +09:00
dc0c317d23 feat: [auth] MNG→SAM 자동 로그인 페이지 구현
- /auto-login?token=xxx 페이지 추가 (기존 세션 로그아웃 후 토큰 로그인)
- /api/auth/token-login 프록시 라우트 (HttpOnly 쿠키 설정)
- publicRoutes에 /auto-login 추가 (인증 없이 접근 허용)
2026-03-11 01:08:09 +09:00
d17b2a11a4 feat: [QMS] 프론트엔드 API 연동 준비 (Phase 3)
- actions.ts: 1일차/2일차 서버 액션 함수 (snake_case→camelCase 변환)
- hooks/useDay1Audit.ts: 기준/매뉴얼 심사 상태 관리 커스텀 훅
- hooks/useDay2LotAudit.ts: 로트 추적 심사 상태 관리 커스텀 훅
- page.tsx: 인라인 상태를 커스텀 훅으로 리팩토링
- USE_MOCK 플래그로 목업↔API 전환 지원
2026-03-10 17:11:56 +09:00
77c5bcde59 feat: [QMS] 목업 데이터 사용 컴포넌트에 Mock 배지 표시
- 6개 컴포넌트에 isMock prop 추가 (ReportList, RouteList, DocumentList, Day1ChecklistPanel, Day1DocumentSection, Day1DocumentViewer)
- 각 컴포넌트 헤더에 amber 톤 Mock 배지 표시
- API 연동 완료 시 isMock prop 제거로 자동 해제
2026-03-10 17:11:56 +09:00
유병철
397eb2c19c feat: 공지 팝업 시스템 구현 및 캘린더/어음/팝업관리 개선
- NoticePopupModal: 공지 팝업 컨테이너/actions 신규 구현
- AuthenticatedLayout에 공지 팝업 연동
- CalendarSection: 일정 타입 확장 및 UI 개선
- BillManagementClient: 기능 확장
- PopupManagement: popupDetailConfig 대폭 확장, 상세/폼 개선
- BoardForm/BoardManagement: 게시판 폼 개선
- LoginPage, logout, userStorage: 인증 관련 소폭 수정
- dashboard types 정비
- claudedocs: 공지팝업 구현, 캘린더 어음연동/일정타입, API changelog 문서 추가
2026-03-10 15:16:41 +09:00
7bd4bd38da feat: [quality] 수주처 선택 UI + client_id 연동 + 수정 저장 개선
- 수주처를 텍스트 입력에서 거래처 검색 선택으로 변경
- 수주 선택 시 거래처+모델 필터 연동 (양방향)
- ProductInspection/Api에 clientId 매핑 추가
- 수정 시 새 개소 locations 필터 (NaN ID 에러 해결)
- SupplierSearchModal 콜백에 id 반환 추가
2026-03-09 21:06:57 +09:00
유병철
68331be0ef feat: 회계/결재/생산/출하/대시보드 다수 개선 및 QA 수정
- BadDebtCollection, BillManagement, CardTransaction, TaxInvoice 회계 개선
- VendorManagement/VendorDetailClient 소폭 추가
- DocumentCreate/DraftBox 결재 기능 개선
- WorkOrder Create/Detail/Edit, ShipmentEdit 생산/출하 개선
- CEO 대시보드: PurchaseStatusSection, receivable/status-issue transformer 정비
- dashboard types/invalidation 확장
- LoginPage, Sidebar, HeaderFavoritesBar 레이아웃 수정
- QMS 페이지, StockStatusDetail, OrderRegistration 소폭 수정
- AttendanceManagement, VacationManagement HR 수정
- ConstructionDetailClient 건설 상세 개선
- claudedocs: 주간 구현내역, 대시보드 QA/수정계획, 결재/품질/생산/출하 문서 추가
2026-03-09 21:06:01 +09:00
유병철
7d369d1404 feat: 계정과목 공통화 및 회계 모듈 전반 개선
- 계정과목 관리를 accounting/common/으로 통합 (AccountSubjectSettingModal 이동)
- GeneralJournalEntry: 계정과목 actions/types 분리, 모달 import 경로 변경
- CardTransactionInquiry: JournalEntryModal/ManualInputModal 개선
- TaxInvoiceManagement: actions/types 리팩토링
- DepositManagement/WithdrawalManagement: 소폭 개선
- ExpectedExpenseManagement: UI 개선
- GiftCertificateManagement: 상세/목록 개선
- BillManagement: BillDetail/Client/index 소폭 추가
- PurchaseManagement/SalesManagement: 상세뷰 개선
- CEO 대시보드: dashboard-invalidation 유틸 추가, useCEODashboard 확장
- OrderRegistration/OrderSalesDetailView 소폭 수정
- claudedocs: 계정과목 통합 계획/분석/체크리스트, 대시보드 검증 문서 추가
2026-03-08 12:44:36 +09:00
74e0e2bf44 fix: InspectionManagement 타입 에러 일괄 수정
- ProductInspectionApi order_items에 document_id, inspection_data 속성 추가
- saveLocationInspection 파라미터를 ProductInspectionData 타입으로 변경
- inspection_data API→Frontend 변환 시 타입 캐스팅 수정
- 로컬 빌드 성공 확인
2026-03-07 02:05:17 +09:00
c94236e15c fix: ProductInspectionApi order_items에 누락된 속성 추가
- document_id, inspection_data 속성 추가
- 빌드 타입 에러 해결
2026-03-07 01:56:30 +09:00
3bade70c5f fix: ProductInspectionData 타입 에러 수정
- saveLocationInspection 파라미터를 Record<string, unknown>에서 ProductInspectionData로 변경
- interface는 index signature가 없어 Record<string, unknown>에 할당 불가
2026-03-07 01:49:38 +09:00
b7c2b99c68 fix: ApiBomItem에 없는 specification 속성 참조 제거
- item.specification fallback 제거 (ApiBomItem에 spec만 존재)
- 빌드 타입 에러 해결
2026-03-07 01:38:23 +09:00
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
649 changed files with 57996 additions and 13934 deletions

View File

@@ -114,3 +114,12 @@ symbol_info_budget:
# Note: the backend is fixed at startup. If a project with a different backend # Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned. # is activated post-init, an error will be returned.
language_backend: language_backend:
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []

102
CLAUDE.md
View File

@@ -271,6 +271,89 @@ function buildCoverageMap(items, spanKey) {
--- ---
## 페이지 모드 라우팅 패턴 (mode=new/edit/view)
**Priority**: 🔴
### 라우팅 규칙
- **별도 `/new` 경로 금지** → `?mode=new` 쿼리파라미터 사용
- **별도 `/edit` 경로 금지** → `?mode=edit` 쿼리파라미터 사용
- 목록과 등록/수정을 **같은 page.tsx에서 분기**
```typescript
// ✅ 올바른 패턴: page.tsx에서 mode 분기
export default function SomePage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
if (mode === 'new') return <SomeForm />;
return <SomeList />;
}
// ✅ 상세+수정: [id] 경로에서 mode 분기
export default function SomeDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <SomeDetail id={params.id} mode={mode} />;
}
```
```typescript
// ❌ 금지 패턴
router.push('/some-page/new') // → router.push('/some-page?mode=new')
router.push('/some-page/123/edit') // → router.push('/some-page/123?mode=edit')
```
### 등록/수정/상세 페이지 헤더
| 위치 | 요소 |
|------|------|
| 상단 좌측 | 페이지 제목 (`<h1>`) |
| 상단 우측 | `← 목록으로` 링크 (`Button variant="link"`) |
```typescript
// ✅ 표준 헤더
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">페이지 제목</h1>
<Button variant="link" className="text-muted-foreground"
onClick={() => router.push(listPath)}>
목록으로
</Button>
</div>
```
### 하단 Sticky 액션 바 (필수)
폼 콘텐츠 아래에 **sticky bottom bar**로 버튼 배치. 취소는 좌측, 주요 액션은 우측.
| 모드 | 좌측 | 우측 |
|------|------|------|
| 등록 (new) | `X 취소` | `💾 저장` |
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
| 수정 (edit) | `X 취소` | `💾 저장` |
```typescript
// ✅ 표준 하단 Sticky 액션 바
<div className="sticky bottom-0 bg-white border-t shadow-sm">
<div className="px-3 py-3 md:px-6 md:py-4 flex items-center justify-between">
<Button variant="outline" onClick={() => router.push(listPath)}>
<X className="h-4 w-4 mr-1" />
취소
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
{isNewMode ? '저장' : '저장'}
</Button>
</div>
</div>
```
**규칙:**
- Card 내부에 버튼 넣지 않음 → sticky 하단 바 사용
- 아이콘 포함: 취소(`X`), 저장(`Save`), 수정(`Pencil`)
- 상세(view) 모드에서 "취소"는 목록으로 이동, "수정"은 `?mode=edit` 전환
---
## Design Popup Policy ## Design Popup Policy
**Priority**: 🟡 **Priority**: 🟡
@@ -326,16 +409,19 @@ const [data, setData] = useState(() => {
--- ---
## Backend API Analysis Policy ## Backend API Policy
**Priority**: 🟡 **Priority**: 🟡
- Backend API 코드는 **분석만**, 직접 수정 안 함 - **신규 API 생성 금지**: 새로운 엔드포인트/컨트롤러 생성은 직접 하지 않음 → 요청 문서로 정리
- 수정 필요 시 백엔드 요청 문서로 정리: - **기존 API 수정/추가 가능**: 이미 존재하는 API의 수정, 필드 추가, 로직 변경은 직접 수행 가능
- 백엔드 경로: `sam_project/sam-api/sam-api` (PHP Laravel)
- 수정 시 기존 코드 패턴(Service-First, 기존 응답 구조) 준수
- 신규 API가 필요한 경우 요청 문서로 정리:
```markdown ```markdown
## 백엔드 API 수정 요청 ## 백엔드 API 신규 요청
### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX) ### 엔드포인트: [HTTP METHOD /api/v1/path]
### 현재 문제: [설명] ### 목적: [설명]
### 수정 요청: [내용] ### 요청/응답 구조: [내용]
``` ```
--- ---
@@ -445,6 +531,7 @@ url: `${API_URL}/api/v1/items?${params.toString()}`
|-----------|----------| |-----------|----------|
| 검색 모달/선택 팝업 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "검색 모달" 섹션 | | 검색 모달/선택 팝업 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "검색 모달" 섹션 |
| 리스트/목록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "리스트 페이지" 섹션 | | 리스트/목록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "리스트 페이지" 섹션 |
| **IntegratedListTemplateV2 적용/리팩토링** | `claudedocs/guides/[GUIDE] common-page-patterns.md`**"IntegratedListTemplateV2 표준 적용"** 섹션 |
| 상세/수정/등록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "상세/폼 페이지" 섹션 | | 상세/수정/등록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "상세/폼 페이지" 섹션 |
| 새 organisms 필요 | `src/components/organisms/index.ts` 먼저 확인 → 없으면 생성 | | 새 organisms 필요 | `src/components/organisms/index.ts` 먼저 확인 → 없으면 생성 |
@@ -452,6 +539,7 @@ url: `${API_URL}/api/v1/items?${params.toString()}`
- 새 파일 만들기 전 `organisms/`, `molecules/` export 목록 확인 - 새 파일 만들기 전 `organisms/`, `molecules/` export 목록 확인
- 검색+선택 모달 → `SearchableSelectionModal<T>` 사용 (직접 Dialog 조합 금지) - 검색+선택 모달 → `SearchableSelectionModal<T>` 사용 (직접 Dialog 조합 금지)
- 리스트 페이지 → `UniversalListPage` 또는 organisms 조합 - 리스트 페이지 → `UniversalListPage` 또는 organisms 조합
- **IntegratedListTemplateV2 사용 시 → 컬럼 설정(`useColumnSettings` + `ColumnSettingsPopover`), 모바일 카드(`renderMobileCard`), 체크박스(`Set<string>`), 테이블 내 필터(`tableHeaderActions`) 필수 적용**
- 상세/폼 → Card + 기존 패턴 따르기 - 상세/폼 → Card + 기존 패턴 따르기
--- ---

130
Jenkinsfile vendored
View File

@@ -1,6 +1,12 @@
pipeline { pipeline {
agent any agent any
parameters {
choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백')
choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경')
string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백')
}
options { options {
disableConcurrentBuilds() disableConcurrentBuilds()
} }
@@ -8,21 +14,78 @@ pipeline {
environment { environment {
DEPLOY_USER = 'hskwon' DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss') RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
PROD_SERVER = '211.117.60.189'
} }
stages { stages {
// ── 롤백: 릴리스 목록 조회 ──
stage('Rollback: List Releases') {
when { expression { params.ACTION == 'rollback' } }
steps {
script {
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/react' : '/home/webservice/react-stage'
def pmName = params.ROLLBACK_TARGET == 'production' ? 'sam-front' : 'sam-front-stage'
sshagent(credentials: ['deploy-ssh-key']) {
def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim()
def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim()
echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ==="
echo "현재 활성: ${current}"
echo "사용 가능:\n${releases}"
}
}
}
}
// ── 롤백: symlink 전환 ──
stage('Rollback: Switch Release') {
when { expression { params.ACTION == 'rollback' } }
steps {
script {
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/react' : '/home/webservice/react-stage'
def pmName = params.ROLLBACK_TARGET == 'production' ? 'sam-front' : 'sam-front-stage'
sshagent(credentials: ['deploy-ssh-key']) {
def targetRelease = params.ROLLBACK_RELEASE
if (!targetRelease?.trim()) {
targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim()
}
// 릴리스 존재 여부 확인
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'"
slackSend channel: '#deploy_react', color: '#FF9800', tokenCredentialId: 'slack-token',
message: "🔄 *react* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
sh """
ssh ${DEPLOY_USER}@${PROD_SERVER} '
ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current &&
cd /home/webservice && pm2 reload ${pmName}
'
"""
slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token',
message: "✅ *react* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}
}
// ── 일반 배포: Checkout ──
stage('Checkout') { stage('Checkout') {
when { expression { params.ACTION == 'deploy' } }
steps { steps {
checkout scm checkout scm
script { script {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim() 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}>" message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
} }
} }
stage('Prepare Env') { stage('Prepare Env') {
when { expression { params.ACTION == 'deploy' } }
steps { steps {
script { script {
if (env.BRANCH_NAME == 'main') { if (env.BRANCH_NAME == 'main') {
@@ -37,16 +100,23 @@ pipeline {
} }
stage('Install') { stage('Install') {
when { expression { params.ACTION == 'deploy' } }
steps { sh 'npm install --prefer-offline' } steps { sh 'npm install --prefer-offline' }
} }
stage('Build') { stage('Build') {
when { expression { params.ACTION == 'deploy' } }
steps { sh 'npm run build' } steps { sh 'npm run build' }
} }
// ── develop → 개발서버 배포 ── // ── develop → 개발서버 배포 ──
stage('Deploy Development') { stage('Deploy Development') {
when { branch 'develop' } when {
allOf {
branch 'develop'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sshagent(credentials: ['deploy-ssh-key']) { sshagent(credentials: ['deploy-ssh-key']) {
sh """ sh """
@@ -63,16 +133,21 @@ pipeline {
// ── main → 운영서버 Stage 배포 ── // ── main → 운영서버 Stage 배포 ──
stage('Deploy Stage') { stage('Deploy Stage') {
when { branch 'main' } when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sshagent(credentials: ['deploy-ssh-key']) { sshagent(credentials: ['deploy-ssh-key']) {
sh """ sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}' ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
rsync -az --delete \ rsync -az --delete \
.next package.json next.config.ts public node_modules \ .next package.json next.config.ts public node_modules \
${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/ ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/
scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production
ssh ${DEPLOY_USER}@211.117.60.189 ' ssh ${DEPLOY_USER}@${PROD_SERVER} '
ln -sfn /home/webservice/react-stage/releases/${RELEASE_ID} /home/webservice/react-stage/current && ln -sfn /home/webservice/react-stage/releases/${RELEASE_ID} /home/webservice/react-stage/current &&
cd /home/webservice && pm2 reload sam-front-stage 2>/dev/null || pm2 start react-stage/current/node_modules/.bin/next --name sam-front-stage -- start -p 3100 && cd /home/webservice && pm2 reload sam-front-stage 2>/dev/null || pm2 start react-stage/current/node_modules/.bin/next --name sam-front-stage -- start -p 3100 &&
cd /home/webservice/react-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true cd /home/webservice/react-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
@@ -97,7 +172,12 @@ pipeline {
// ── main → Production 재빌드 (운영 환경변수) ── // ── main → Production 재빌드 (운영 환경변수) ──
stage('Rebuild for Production') { stage('Rebuild for Production') {
when { branch 'main' } when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production" sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production"
sh 'npm run build' sh 'npm run build'
@@ -106,16 +186,21 @@ pipeline {
// ── main → 운영서버 Production 배포 ── // ── main → 운영서버 Production 배포 ──
stage('Deploy Production') { stage('Deploy Production') {
when { branch 'main' } when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
steps { steps {
sshagent(credentials: ['deploy-ssh-key']) { sshagent(credentials: ['deploy-ssh-key']) {
sh """ sh """
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}' ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
rsync -az --delete \ rsync -az --delete \
.next package.json next.config.ts public node_modules \ .next package.json next.config.ts public node_modules \
${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/ ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/
scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.production scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/.env.production
ssh ${DEPLOY_USER}@211.117.60.189 ' ssh ${DEPLOY_USER}@${PROD_SERVER} '
ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current && ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
cd /home/webservice && pm2 reload sam-front && cd /home/webservice && pm2 reload sam-front &&
cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
@@ -128,12 +213,23 @@ pipeline {
post { post {
success { success {
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token', script {
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" if (params.ACTION == 'deploy') {
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 { failure {
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token', script {
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>" if (params.ACTION == 'deploy') {
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}>"
} else {
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *react* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
} }
} }
} }

View File

@@ -0,0 +1,96 @@
# QMS 점검표 항목 관리 기능
## 개요
품질인정심사 시스템(QMS)의 "화면 설정" 패널에 **점검표 항목 관리** 섹션을 추가하여,
카테고리/항목의 CRUD + 순서 변경 + 버전 관리를 지원한다.
## 현재 구조
- 점검표 데이터: `MOCK_DAY1_CATEGORIES` (mockData.ts) — Mock 상태
- 타입: `ChecklistCategory``ChecklistSubItem[]`
- 설정 패널: `AuditSettingsPanel.tsx` — 레이아웃/점검표 옵션 토글만 존재
- 데이터 훅: `useDay1Audit.ts``USE_MOCK = true`
## 구현 범위
### 1. 점검표 템플릿 관리 UI (화면 설정 패널 내)
**위치**: AuditSettingsPanel → 새 섹션 "점검표 항목 관리"
**기능**:
- 현재 버전 표시 + 버전 이력 드롭다운
- 카테고리 CRUD (추가/수정/삭제)
- 하위 항목 CRUD (추가/수정/삭제)
- 순서 변경 (위/아래 버튼 — 드래그앤드롭 라이브러리 미사용)
- "저장 (새 버전 생성)" 버튼 → API 호출
- "초기화" 버튼 → 마지막 저장 상태로 복원
### 2. 데이터 구조 (프론트)
```typescript
// 점검표 템플릿 버전
interface ChecklistTemplateVersion {
id: string;
version: number;
createdAt: string;
createdBy: string;
description?: string; // 변경 사유
}
// 점검표 템플릿 (API 응답)
interface ChecklistTemplate {
id: string;
currentVersion: number;
categories: ChecklistCategory[]; // 기존 타입 재사용
versions: ChecklistTemplateVersion[];
}
```
### 3. API 엔드포인트 (Mock → 추후 연동)
| Method | Endpoint | 설명 |
|--------|----------|------|
| GET | `/api/v1/qms/checklist-templates/current` | 현재 템플릿 조회 |
| POST | `/api/v1/qms/checklist-templates` | 새 버전 저장 |
| GET | `/api/v1/qms/checklist-templates/versions` | 버전 이력 조회 |
| GET | `/api/v1/qms/checklist-templates/versions/:id` | 특정 버전 조회 |
| POST | `/api/v1/qms/checklist-templates/versions/:id/restore` | 버전 복원 |
### 4. UI 구성 (설정 패널 내)
```
━━ 점검표 항목 관리 ━━
[v3 (2026-03-10) ▾] ← 버전 셀렉트 (이력 조회/복원)
── 카테고리 ──
┌─────────────────────────────────────┐
│ [⬆][⬇] 1. 원재료 품질관리 기준 [✏️][🗑] │
│ [⬆][⬇] 수입검사 기준 확인 [✏️][🗑] │
│ [⬆][⬇] 불합격품 처리 기준 확인 [✏️][🗑] │
│ [⬆][⬇] 자재 보관 기준 확인 [✏️][🗑] │
│ [+ 항목 추가] │
├─────────────────────────────────────┤
│ [⬆][⬇] 2. 제조공정 관리 기준 [✏️][🗑] │
│ ... │
└─────────────────────────────────────┘
[+ 카테고리 추가]
━━━━━━━━━━━━━━━━━━━━━━━━
[초기화] [저장 (새 버전)]
```
### 5. 작업 목록
- [ ] types.ts에 템플릿 관련 타입 추가
- [ ] ChecklistTemplateEditor 컴포넌트 생성 (편집 UI)
- [ ] AuditSettingsPanel에 탭/섹션 추가 ("화면 설정" / "점검표 관리")
- [ ] useChecklistTemplate 훅 생성 (상태 관리 + Mock 데이터)
- [ ] page.tsx 연동 (훅 → 설정 패널 props)
- [ ] 버전 이력 UI (Select 드롭다운 + 복원 확인)
### 6. 설계 결정
- **드래그앤드롭 미사용**: 패키지 추가 없이 ⬆⬇ 버튼으로 순서 변경
- **설정 패널 분리**: 기존 "화면 설정"과 "점검표 관리"를 탭으로 분리
- **Mock 우선**: `USE_MOCK = true`로 시작, API 연동 시 교체
- **인라인 편집**: 항목명 클릭 시 input으로 전환 (별도 모달 없음)
- **낙관적 업데이트**: 로컬 편집 → 저장 버튼 클릭 시 한번에 API 호출

View File

@@ -0,0 +1,54 @@
# ESLint 코드 정리 체크리스트
## 점검 결과 요약
- **TypeScript**: 0건 (완벽)
- **ESLint**: 923 errors + 220 warnings (1,529개 파일 중 399개)
## 수정 대상 (exhaustive-deps 제외 - 동작 변경 위험)
### ✅ 완료
| 룰 | 건수 | 상태 | 수정 내용 |
|---|---|---|---|
| `no-unreachable` | 7 | ✅ 완료 | 도달 불가 catch 블록 제거 (construction actions 3파일) |
| `no-constant-binary-expression` | 6 | ✅ 완료 | `false && ...` 조건 제거 (MasterFieldTab, SectionsTab) |
| `no-useless-escape` | 6 | ✅ 완료 | 불필요한 `\` 제거 (CurrencyField, currency-input, number-input, locale.ts) |
| `no-case-declarations` | 21 | ✅ 완료 | switch case에 `{}` 블록 추가 (5파일) |
### ⏳ 미완료
| 룰 | 건수 | 상태 | 수정 방법 |
|---|---|---|---|
| `no-unused-vars` | 707 | ⏳ 대기 | `eslint-plugin-unused-imports` 자동 수정 예정 |
## unused-vars 수정 계획
### 준비 상태
- `eslint-plugin-unused-imports` 이미 설치됨 (npm install -D 완료)
- eslint.config.mjs 아직 미수정
### 실행 순서
```bash
# 1. eslint.config.mjs에 플러그인 임시 추가
# 2. npx eslint --fix src/ (unused-imports 룰만)
# 3. eslint.config.mjs 원복
# 4. npx eslint src/ 로 결과 확인
# 5. eslint-plugin-unused-imports 패키지 제거
```
### unused-vars 파일 분포 (284개 파일)
- src/app/: 44파일
- src/components/business/: 33파일
- src/components/accounting+hr/: 42파일
- src/components/items+orders+quotes+production/: 55파일
- src/components/ 기타: 95파일
- src/lib+stores+types/: 15파일
## 수정하지 않는 항목
| 룰 | 건수 | 사유 |
|---|---|---|
| `no-explicit-any` | 155 | warning 수준, 타입 정의 필요 (별도 작업) |
| `exhaustive-deps` | 24 | useEffect 재실행 빈도 변경 위험 |
| `no-img-element` | 39 | next/image 전환은 별도 작업 |
| `no-undef` | 168 | globals 설정 추가 필요 (sessionStorage 등) |

View File

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

View File

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

View File

@@ -0,0 +1,103 @@
# [IMPL] 공지 팝업 사용자 표시 연동
> 관리자가 등록한 팝업을 사용자에게 자동 표시하는 기능 구현
## 현황
| 구분 | 상태 |
|------|------|
| 관리자 팝업 관리 UI (CRUD) | ✅ 완성 |
| 백엔드 API (`/api/v1/popups`) | ✅ 완성 |
| `NoticePopupModal` 표시 컴포넌트 | ✅ 완성 |
| 활성 팝업 조회 서버 액션 | ✅ 완성 |
| 레이아웃 자동 표시 연동 | ✅ 완성 |
| 부서별 팝업 필터링 (백엔드) | ✅ 완성 (2026-03-10) |
| 부서별 팝업 필터링 (프론트) | ✅ 완성 (2026-03-10) |
| 부서 선택 UI (관리자 폼) | ✅ 완성 (2026-03-10) |
## 구현 범위 (프론트만)
### 1. `getActivePopups()` 서버 액션
- 위치: `src/components/common/NoticePopupModal/actions.ts`
- `GET /api/v1/popups?status=active` 호출
- 기존 `PopupApiData``NoticePopupData` 변환
### 2. `NoticePopupContainer` 컴포넌트
- 위치: `src/components/common/NoticePopupModal/NoticePopupContainer.tsx`
- 로그인 후 활성 팝업 fetch
- `isPopupDismissedForToday()` 필터링
- 여러 개 팝업 순차 표시 (하나 닫으면 다음 팝업)
### 3. `AuthenticatedLayout` 연동
- `NoticePopupContainer` 렌더링 추가
## 기존 파일 활용
```
src/components/common/NoticePopupModal/
├── NoticePopupModal.tsx ← 기존 (수정 없음)
├── NoticePopupContainer.tsx ← 신규
└── actions.ts ← 신규
src/components/settings/PopupManagement/
├── utils.ts ← transformApiToFrontend 재사용
└── types.ts ← PopupApiData 타입 재사용
src/layouts/AuthenticatedLayout.tsx ← NoticePopupContainer 추가
```
## 동작 흐름
```
로그인 → AuthenticatedLayout 마운트
→ NoticePopupContainer useEffect
→ localStorage에서 user.department_id 조회
→ getActivePopups(departmentId) API 호출
→ 백엔드 scopeForUser(departmentId) 적용
→ target_type='all' 팝업 + 해당 부서 팝업 반환
→ 날짜 범위(startDate~endDate) 필터
→ isPopupDismissedForToday() 필터
→ 표시할 팝업 있으면 첫 번째 팝업 모달 표시
→ 닫기 클릭 → "오늘 하루 안 보기" 체크 시 localStorage 저장
→ 다음 팝업 표시 (없으면 종료)
```
---
## [2026-03-10] 부서별 팝업 필터링 + 부서 선택 UI
### 배경
팝업 대상이 "부서별"일 때 어떤 부서인지 선택할 수 없었고, 사용자에게도 부서 기반 필터링이 적용되지 않았음.
### 변경사항
#### 백엔드 (sam-api)
- `MemberService::getUserInfoForLogin()` — 로그인 응답에 `department_id` 추가
- `PopupService``scopeForUser(?int $departmentId)` 스코프로 부서별 필터링
#### 프론트엔드
| 파일 | 변경 |
|------|------|
| `LoginPage.tsx` | localStorage user에 `department_id` 저장 |
| `NoticePopupContainer.tsx` | `user.department_id``getActivePopups()`에 전달 |
| `popupDetailConfig.ts` | `target` 필드를 custom 렌더로 변경, `TargetSelectorField` 컴포넌트 추가 |
| `PopupDetailClientV2.tsx` | `handleSubmit`에서 `decodeTargetValue()``targetDepartmentId` 추출 |
| `types.ts` | `Popup.targetId`, `Popup.targetName` 필드 추가 |
| `utils.ts` | `transformApiToFrontend``targetId`, `targetName` 매핑 추가 |
| `actions.ts` | `getDepartmentList()` 서버 액션 추가 |
### 핵심 구현: 대상 필드 값 인코딩
```typescript
// 단일 form field에 target_type + department_id를 함께 저장
encodeTargetValue('department', 13) 'department:13'
decodeTargetValue('department:13') { targetType: 'department', departmentId: 13 }
encodeTargetValue('all') 'all'
```
### TargetSelectorField 동작
```
대상 Select: [전사 | 부서별]
→ "부서별" 선택 시 → getDepartmentList() API 호출
→ 부서 Select 추가 표시: [개발팀 | 영업팀 | ...]
→ 부서 선택 시 form value = 'department:13'
```

View File

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

View File

@@ -0,0 +1,285 @@
# 결재 모듈 QA 검증 보고서 및 수정 계획서
**작성일**: 2026-03-16
**검증 대상**: 결재관리 모듈 전체 (기안함, 결재함, 참조함, 완료함)
**검증 범위**: 문서 분류/양식 선택, 등록/수정/삭제, 벨리데이션, 파일업로드
**상태**: Phase 0~3 완료, 버그 수정 5건 완료 및 재검수 통과, Phase 2-B 미완료
---
## Phase 0: 문서 분류 / 양식 선택 검증 ✅ 완료
### 7개 카테고리, 17개 양식 전체 목록 확인
| 카테고리 | 양식 수 | 양식 목록 | 상태 |
|---------|--------|----------|------|
| 일반 (3) | 3 | 근태신청, 사유서, 품의서 | ✅ |
| 경비 (2) | 2 | 지출결의서, 비용견적서 | ✅ |
| 인사 (2) | 2 | 연차사용촉진 통지서 (1차), 연차사용촉진 통지서 (2차) | ✅ |
| 총무 (2) | 2 | 공문서, 이사회의사록 | ✅ |
| 재무 (1) | 1 | 견적서 | ✅ |
| 총무/기타 (2) | 2 | 위임장, 사용인감계 | ✅ |
| 증명서 (5) | 5 | 사직서, 위촉증명서, 경력증명서, 재직증명서, 사용인감계 | ✅ |
**결론**: 2단계 Select (카테고리 → 양식)이 정상 동작하며 모든 양식이 노출됨
---
## Phase 1: 등록/수정/삭제 검증 ✅ 완료
### Phase 1-A: 일반 카테고리 ✅
#### 품의서 (proposal) — 전용 폼 ✅
| 테스트 항목 | 결과 | 비고 |
|-----------|------|------|
| 양식 선택 → 폼 렌더링 | ✅ | 제목, 거래처, 내용, 사유, 예상비용, 첨부파일 |
| 미리보기 | ✅ | DocumentDetailModal에 정상 렌더링 |
| 벨리데이션 (결재선 미지정) | ✅ | "결재선을 지정해주세요" toast |
| 임시저장 | ✅ | AP-20260316-0001 발급 |
| 상신 | ✅ | AP-20260316-0002 발급, 결재대기 전환 |
| 수정 (기안함에서 클릭) | ✅ | 모든 필드 복원, 제목 변경 후 저장 성공 |
| 삭제 | ✅ | 확인 다이얼로그 후 삭제 성공 |
#### 근태신청 (attendance_request) — 동적 폼 ✅
| 테스트 항목 | 결과 | 비고 |
|-----------|------|------|
| 양식 선택 → 폼 렌더링 | ✅ | DynamicFormRenderer 5필드 정상 |
| 미리보기 | ✅ | 동적 폼 미리보기 정상 |
| 임시저장 | ✅ | 부분 입력 시 성공 (빈 폼은 실패 — BUG #13) |
| 상신 | ✅ | 부분 입력으로도 상신 성공 |
#### 사유서 (reason_report) — 동적 폼 ✅
| 테스트 항목 | 결과 | 비고 |
|-----------|------|------|
| 양식 선택 → 폼 렌더링 | ✅ | DynamicFormRenderer 정상 |
| 미리보기 | ✅ | 정상 |
### Phase 1-B: 경비 카테고리 ✅
#### 지출결의서 (expenseReport) — 전용 폼 ✅
| 테스트 항목 | 결과 | 비고 |
|-----------|------|------|
| 양식 선택 → 폼 렌더링 | ✅ | 항목 추가/삭제 테이블, 카드 정보 |
| 미리보기 | ✅ | 정상 |
| 임시저장 → 수정 → 상신 | ✅ | 전체 CRUD 정상 |
#### 비용견적서 (expenseEstimate) — 전용 폼 ✅
| 테스트 항목 | 결과 | 비고 |
|-----------|------|------|
| 양식 선택 → 폼 렌더링 | ✅ | 항목 테이블, 지출합계/계좌잔액/최종차액 자동계산 |
| 미리보기 | ✅ | 정상 |
| 임시저장 → 수정 → 상신 | ✅ | 전체 CRUD 정상 |
### Phase 1-C: 나머지 카테고리 ✅
| 카테고리 | 양식 | 렌더링 | 미리보기 | 비고 |
|---------|------|--------|---------|------|
| 인사 | 연차촉진 1차 | ✅ | ✅ | 전체 CRUD 테스트 완료 |
| 인사 | 연차촉진 2차 | ✅ | ✅ | |
| 총무 | 공문서 | ✅ | ✅ | |
| 재무 | 견적서 | ✅ | ✅ | |
| 총무/기타 | 이사회의사록 | ✅ | ✅ | |
| 총무/기타 | 위임장 | ✅ | ✅ | 경미 a11y 이슈 (BUG #12) |
| 증명서 | 사용인감계 | ✅ | ✅ | 경미 a11y 이슈 (BUG #12) |
| 증명서 | 사직서 | ✅ | ✅ | |
| 증명서 | 위촉증명서 | ✅ | ✅ | |
| 증명서 | 경력증명서 | ✅ | ✅ | |
| 증명서 | 재직증명서 | ✅ | ✅ | |
---
## Phase 2: 벨리데이션 체크 및 파일업로드 ✅ 완료
### 벨리데이션 테스트 결과
| 테스트 시나리오 | 결과 | 동작 |
|--------------|------|------|
| 결재자 미지정 → 상신 | ✅ | "결재선을 지정해주세요." toast (프론트엔드) |
| 결재자 미지정 + 빈 폼 → 상신 | ✅ | 결재선 검증이 먼저 작동 |
| 결재자 지정 + 빈 폼 → 상신 | ✅ | "내용은(는) 필수 항목입니다." toast (백엔드 API) |
| 빈 폼 → 임시저장 | ❌ BUG #13 | 백엔드가 임시저장에도 content 필수 검증 적용 |
| 부분 입력 → 임시저장 | ✅ | AP-20260316-0009 발급, 성공 |
| 부분 입력 → 상신 | ⚠️ | 성공하지만 필드별 검증 부재 (BUG #14) |
| 임시저장 반복 클릭 | ❌ BUG #11 | 매번 새 문서 생성 (중복) |
### 파일 업로드 테스트 결과
| 테스트 항목 | 결과 | 비고 |
|-----------|------|------|
| FileDropzone 렌더링 (품의서) | ✅ | "클릭하거나 파일을 드래그하세요" |
| 이미지 파일 업로드 | ✅ | test-upload.png 정상 첨부 |
| 첨부 파일 표시 | ✅ | "test-upload.png (새 파일) 73 B" |
| 첨부 파일 삭제 | ✅ | 삭제 후 "첨부된 파일이 없습니다" 복원 |
---
## Phase 2-B: 대시보드 연동 검증 ⏳ 미완료
---
## 발견된 버그 목록 (전체)
### 🔴 CRITICAL
#### BUG #11: 임시저장 후 URL 미갱신 → 중복 문서 생성 + 삭제 불가
**증상**:
1. 새 문서 작성(`?mode=new`)에서 임시저장 성공 후 URL이 `?mode=new`로 유지
2. `isEditMode`가 false인 채로 유지됨
3. 임시저장을 다시 클릭하면 `createApproval()` 재호출 → **매번 새 문서 생성** (AP-0009, AP-0010...)
4. 삭제 버튼 클릭 시 `isEditMode`가 false이므로 API 호출 없이 `router.back()` 실행
**재현**: 새 문서 → 내용 입력 → 임시저장 → 임시저장 반복 → 기안함에서 중복 문서 확인
**파일**: `src/components/approval/DocumentCreate/index.tsx` lines 526-569
**수정 방안**:
```typescript
// handleSaveDraft 성공 후 URL 갱신 추가
if (result.success && result.data?.id) {
// URL을 edit 모드로 전환하여 이후 저장이 updateApproval을 호출하도록
router.replace(`/approval/draft/new?id=${result.data.id}&mode=edit`, { scroll: false });
// 또는 state로 관리
setDocumentId(String(result.data.id));
}
```
**우선순위**: 🔴 CRITICAL — 데이터 중복 생성, 삭제 불가
---
### 🟡 MEDIUM
#### BUG #1: 상신 후 기안함 리다이렉트 시 목록 데이터 미로드
**증상**: 문서 상신 후 기안함으로 리다이렉트되지만 목록이 0건으로 표시. 새로고침 후 정상.
**파일**: `src/components/approval/DocumentCreate/index.tsx` (handleSubmit → router.push)
**수정 방안**: DraftBox의 데이터 로딩에 pathname 의존성 추가 또는 invalidate 후 딜레이
**우선순위**: 🟡 MEDIUM — 새로고침으로 해결 가능
---
#### BUG #13: 빈 폼 임시저장 시 백엔드 검증 에러
**증상**: 폼 필드를 하나도 입력하지 않은 상태에서 임시저장 클릭 시 "내용은(는) 필수 항목입니다." 에러
**원인**: 동적 폼의 `dynamicFormData``{}`일 때 백엔드가 content 필수 검증 적용
**수정 방안**:
- 프론트엔드: 빈 폼일 때 프론트엔드에서 "최소 1개 필드를 입력해주세요" 안내
- 또는 백엔드: 임시저장(`is_submitted=false`) 시 content 필수 검증 제외
**우선순위**: 🟡 MEDIUM — 임시저장 UX 개선
---
#### BUG #14: 부분 입력 폼 상신 시 필드별 벨리데이션 미비
**증상**: 근태신청에서 신청자와 사유만 입력하고 신청유형/기간/일수 미입력 상태로 상신 성공
**원인**: 백엔드에서 `content` JSON 내부 필드별 필수값 검증을 하지 않음
**수정 방안**: 백엔드에서 양식별 required 필드 검증 추가 필요
**우선순위**: 🟡 MEDIUM — 불완전한 문서가 상신될 수 있음
---
### 🟢 LOW
#### BUG #12: 폼 헤더에 로딩 텍스트 a11y 이슈
**증상**: PowerOfAttorneyForm, SealUsageForm에서 `<h3>` 안에 로딩 `<span>` 포함
- 로딩 중: "위임인 불러오는 중..." / "회사 정보 불러오는 중..."이 h3의 일부로 읽힘
- 로딩 완료 후: 정상
**파일**:
- `src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx` line 47
- `src/components/approval/DocumentCreate/SealUsageForm.tsx` line 101
**수정 방안**: 로딩 텍스트를 `<h3>` 외부로 이동하거나 `aria-hidden` 추가
**우선순위**: 🟢 LOW — 일시적 상태, 기능 영향 없음
---
### ✅ 수정 완료 (이전 세션에서 해결)
| 버그 | 증상 | 수정 내용 |
|------|------|----------|
| BUG #2 (서버 hang) | startTransition + 서버 액션 deadlock | startTransition 제거, try/catch 패턴 적용 |
| BUG #3 (Select 경고) | controlled/uncontrolled 전환 | value에 undefined 사용 + key prop |
| BUG #7 (content empty) | 전용 폼 content가 빈 객체 | getDocumentContent()에 구조화된 데이터 추가 |
| BUG #8 (명칭 불일치) | "지출 예상 내역서" → "비용견적서" | 11개 파일 명칭 통일 |
| BUG #9 (key 중복 에러) | 저장된 항목 복원 시 id 누락 | transformApiToFormData()에 fallback ID 생성 |
| BUG #10 (null Input) | Input value에 null 전달 | `?? ''` null guard 추가 |
---
## 수정 우선순위 정리
| 순위 | 버그 | 심각도 | 수정 난이도 | 파일 |
|------|------|--------|-----------|------|
| 1 | BUG #11 (중복 문서 생성) | 🔴 CRITICAL | 낮음 | `DocumentCreate/index.tsx` |
| 2 | BUG #1 (리다이렉트 미로드) | 🟡 MEDIUM | 중간 | `DocumentCreate/index.tsx`, `DraftBox/index.tsx` |
| 3 | BUG #13 (빈 폼 임시저장) | 🟡 MEDIUM | 낮음 | `DocumentCreate/index.tsx` (프론트) 또는 백엔드 |
| 4 | BUG #14 (필드별 검증 미비) | 🟡 MEDIUM | 높음 | 백엔드 API |
| 5 | BUG #12 (a11y 로딩 텍스트) | 🟢 LOW | 낮음 | `PowerOfAttorneyForm.tsx`, `SealUsageForm.tsx` |
---
## 테스트 데이터 정리 필요
QA 과정에서 생성된 테스트 문서:
- AP-20260316-0009 (근태신청, 임시저장) — 중복 1
- AP-20260316-0010 (근태신청, 임시저장) — 중복 2
- AP-20260316-0011 (근태신청, 결재대기) — 부분 입력 상신
- AP-20260316-0008 (연차촉진1차, 임시저장)
---
## 버그 수정 및 재검수 결과 ✅ 완료
### 수정 완료 (2026-03-16 14:00)
| BUG | 수정 내용 | 재검수 결과 | 검증 방법 |
|-----|----------|-----------|----------|
| **#11** (중복 생성) | `savedDocId` state 추가, 첫 저장 후 `isEditMode` 전환 | ✅ PASS | 1차 저장 `createApproval()` → 2차 저장 `updateApproval(55, ...)` 확인 |
| **#1** (리다이렉트 미로드) | `router.back()``router.push('/approval/draft')` 변경 | ✅ PASS | 상신 후 기안함 9건 정상 로드, 토스트 표시 |
| **#13** (빈 폼 임시저장) | 프론트엔드 사전 검증 추가 (동적/전용 폼 내용 체크) | ✅ PASS | "문서 내용을 최소 1개 이상 입력해주세요" 토스트 표시 |
| **#14** (필수 필드 검증) | 프론트엔드 동적 폼 required 필드 검증 추가 | ✅ PASS | "필수 항목을 입력해주세요: 신청유형, 기간, 일수" 토스트 표시 |
| **#12** (a11y 로딩) | `<h3>` 내부 로딩 span → `<div>` wrapper로 sibling 분리 | ✅ PASS | heading에 로딩 텍스트 미포함 확인 |
### 수정 파일 목록
| 파일 | 수정 내용 |
|------|----------|
| `src/components/approval/DocumentCreate/index.tsx` | BUG #11, #1, #13, #14 |
| `src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx` | BUG #12 |
| `src/components/approval/DocumentCreate/SealUsageForm.tsx` | BUG #12 |
---
## 전체 QA 진행 상태
| Phase | 상태 | 비고 |
|-------|------|------|
| Phase 0: 문서 분류/양식 선택 | ✅ 완료 | 7카테고리 17양식 전체 확인 |
| Phase 1-A: 일반 카테고리 CRUD | ✅ 완료 | 품의서 전체 CRUD, 근태신청/사유서 렌더링+미리보기 |
| Phase 1-B: 경비 카테고리 CRUD | ✅ 완료 | 지출결의서, 비용견적서 전체 CRUD |
| Phase 1-C: 나머지 카테고리 | ✅ 완료 | 11개 양식 렌더링+미리보기 전체 통과 |
| Phase 2: 벨리데이션/파일업로드 | ✅ 완료 | 7개 벨리데이션 시나리오, 파일 업로드/삭제 테스트 |
| Phase 2-B: 대시보드 연동 | ⏳ 미완료 | |
| Phase 3: 버그 정리/수정 계획 | ✅ 완료 | 본 문서 |
| **버그 수정 + 재검수** | **✅ 완료** | **5건 수정, 5건 화면 재검수 통과** |
### QA 중 생성된 테스트 데이터
- AP-20260316-0012 (근태신청, 결재대기) — BUG #11 재검수용

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

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

View File

@@ -0,0 +1,45 @@
# 어음 만기일 캘린더 연동
**날짜**: 2026-03-10
**범위**: Backend (CalendarService) + Frontend (CalendarSection)
## 변경 요약
대시보드 캘린더에 어음(Bill) 만기일을 5번째 데이터 소스로 추가.
기존 4개 소스(작업지시, 계약, 휴가, 범용일정)와 동일한 패턴.
## Backend 변경
### `app/Services/CalendarService.php`
- `use App\Models\Tenants\Bill` import 추가
- `getSchedules()`: `$type === 'bill'` 필터 조건 및 merge 추가
- `getBillSchedules()` 메서드 신규:
- `maturity_date` 기준 날짜 범위 필터
- `paymentComplete`, `dishonored` 상태 제외
- 아이템 형식: `bill_{id}`, `[만기] {거래처명} {금액}원`
- `type: 'bill'`, `isAllDay: true`
## Frontend 변경
### `src/lib/api/dashboard/types.ts`
- `CalendarScheduleType``'bill'` 추가
### `src/components/business/CEODashboard/types.ts`
- `CalendarScheduleItem.type``'bill'` 추가
- `CalendarTaskFilterType``'bill'` 추가
### `src/components/business/CEODashboard/sections/CalendarSection.tsx`
- `SCHEDULE_TYPE_COLORS`: `bill: 'amber'`
- `SCHEDULE_TYPE_LABELS`: `bill: '어음'`
- `SCHEDULE_TYPE_BADGE_COLORS`: `bill: amber 배지 스타일`
- `TASK_FILTER_OPTIONS`: `{ value: 'bill', label: '어음' }`
- `ExtendedTaskFilterType`: `'bill'` 추가
- 모바일 리스트뷰 `colorMap`: `bill: 'bg-amber-500'`
## 검증 방법
1. 대시보드 캘린더에서 어음 만기일이 amber 색상 점으로 표시되는지 확인
2. 캘린더 필터에서 "어음" 선택 시 어음 일정만 필터링되는지 확인
3. 어음 만기일 클릭 시 `[만기] 거래처명 금액원` 형식으로 표시되는지 확인
4. 기존 일정(일정/발주/시공/기타) 정상 동작 확인

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 신규 | 모달 확장 |
| 🟡 중 | 매출 현황, 매입 현황, 시공 현황, 근태 현황 | 신규 섹션 |
| 🟡 중 | 생산 현황 | 복잡한 공정 집계 |
| 🟢 하 | 미출고 내역, 일별 매출/매입 | 단순 조회 |

View File

@@ -0,0 +1,122 @@
# sam-api 변경 내역 (2026-03-09)
**13개 커밋** (중복 1건 제외 실질 12건)
---
## feat: 신규 기능 (6건)
### 1. [database] codebridge 이관 완료 테이블 58개 삭제
- **커밋**: `28ae481` / `74e3c21` (동일 커밋 2건)
- **작업자**: 권혁성
- **변경 파일**: 마이그레이션 1개
- **내용**:
- sam DB → codebridge DB 이관 완료된 58개 테이블 DROP
- FK 체크 비활성화 후 일괄 삭제
- 복원 경로: `~/backups/sam_codebridge_tables_20260309.sql`
### 2. [결재] 테넌트 부트스트랩에 기본 결재 양식 자동 시딩
- **커밋**: `45a207d`
- **작업자**: 권혁성
- **변경 파일**: `RecipeRegistry.php`, `ApprovalFormsStep.php` (신규)
- **내용**:
- ApprovalFormsStep 신규 생성 (proposal, expenseReport, expenseEstimate, attendance_request, reason_report)
- RecipeRegistry STANDARD 레시피에 등록
- 테넌트 생성 시 자동 실행, 기존 테넌트는 `php artisan tenants:bootstrap --all`
### 3. [quality] 검사 상태 자동 재계산 + 수주처 선택 연동
- **커밋**: `3fc5f51`
- **작업자**: 권혁성
- **변경 파일**: `QualityDocumentLocation.php`, `QualityDocumentService.php`
- **내용**:
- 개소별 inspection_status를 검사 데이터 기반 자동 판정 (15개 판정필드 + 사진 유무 → pending/in_progress/completed)
- 문서 status를 개소 상태 집계로 자동 재계산
- transformToFrontend에 client_id 매핑 추가
### 4. [현황판/악성채권] 카드별 sub_label 추가
- **커밋**: `56c60ec`
- **작업자**: 유병철
- **변경 파일**: `BadDebtService.php`, `StatusBoardService.php`
- **내용**:
- BadDebtService: 카드별(전체/추심중/법적조치/회수완료) sub_labels 추가
- StatusBoardService: 악성채권(최다 금액 거래처명), 신규거래처(최근 등록 업체명), 결재(최근 결재 제목) sub_label 추가
### 5. [복리후생] 상세 조회 커스텀 날짜 범위 필터
- **커밋**: `60c4256`
- **작업자**: 유병철
- **변경 파일**: `WelfareController.php`, `WelfareService.php`
- **내용**:
- start_date, end_date 쿼리 파라미터 추가
- 커스텀 날짜 범위 지정 시 해당 범위로 일별 사용 내역 조회
- 미지정 시 기존 분기 기준 유지
### 6. [finance] 더존 Smart A 표준 계정과목 추가 시딩
- **커밋**: `1d5d161`
- **작업자**: 유병철
- **변경 파일**: 마이그레이션 1개 (467줄)
- **내용**:
- 기획서 14장 기준 누락분 보완
- tenant_id + code 중복 시 skip (기존 데이터 보호)
---
## fix: 버그 수정 (4건)
### 7. [현황판] 결재 카드 조회에 approvalOnly 스코프 추가
- **커밋**: `ee9f4d0`
- **작업자**: 유병철
- **변경 파일**: `StatusBoardService.php`
- **내용**: ApprovalStep 쿼리에 approvalOnly() 스코프 적용, 결재 유형만 필터링
### 8. [악성채권] tenant_id ambiguous 에러 + JOIN 컬럼 prefix 보완
- **커밋**: `3929c5f`, `ca259cc`
- **작업자**: 유병철
- **변경 파일**: `BadDebtService.php`, `StatusBoardService.php`
- **내용**:
- JOIN 쿼리에서 `bad_debts.tenant_id`로 테이블 명시
- is_active, status 컬럼에도 `bad_debts.` prefix 추가
### 9. [세금계산서] NOT NULL 컬럼 null 방어 처리
- **커밋**: `1861f4d`
- **작업자**: 유병철
- **변경 파일**: `TaxInvoiceService.php`
- **내용**: supplier/buyer corp_num, corp_name null→빈문자열 보정 (ConvertEmptyStringsToNull 미들웨어 대응)
### 10. [세금계산서] 매입/매출 방향별 필수값 조건 분리
- **커밋**: `c62e59a`
- **작업자**: 유병철
- **변경 파일**: `CreateTaxInvoiceRequest.php`
- **내용**: 매입(supplier 필수), 매출(buyer 필수) — `required → required_if:direction` 조건부 검증
---
## refactor: 리팩토링 (1건)
### 11. [세금계산서/바로빌] ApiResponse::handle() 클로저 패턴 통일
- **커밋**: `e6f13e3`
- **작업자**: 유병철
- **변경 파일**: `BarobillSettingController.php`, `TaxInvoiceController.php`
- **내용**:
- 전체 액션 클로저 방식 전환 (show/save/testConnection, index/show/store/update/destroy/issue/bulkIssue/cancel/checkStatus/summary)
- 중간 변수 할당 제거, 일관된 응답 패턴 적용
- **-38줄** (91→40+27 구조 정리)
---
## 영향받는 주요 서비스 파일
| 파일 | 변경 횟수 | 도메인 |
|------|----------|--------|
| `StatusBoardService.php` | 4회 | 현황판/대시보드 |
| `BadDebtService.php` | 3회 | 악성채권 |
| `TaxInvoiceService.php` | 1회 | 세금계산서 |
| `TaxInvoiceController.php` | 1회 | 세금계산서 |
| `QualityDocumentService.php` | 1회 | 품질검사 |
| `WelfareService.php` | 1회 | 복리후생 |
## 작업자별 커밋 수
| 작업자 | 커밋 수 | 주요 도메인 |
|--------|---------|-------------|
| 유병철 | 9건 | 현황판, 악성채권, 세금계산서, 복리후생, 계정과목 |
| 권혁성 | 4건 | DB 이관, 결재 시딩, 품질검사 |

View File

@@ -0,0 +1,77 @@
# 캘린더 신규 일정 타입 추가 (결제예정/납기/출고)
**작업일**: 2026-03-10
**목적**: CEO 대시보드 캘린더에서 자금/물류/납기 일정을 한눈에 파악
---
## 추가된 타입
| 타입 | 라벨 | 색상 | ID 형식 | 제목 형식 |
|------|------|------|---------|----------|
| `expected_expense` | 결제예정 | rose (분홍) | `expense_{id}` | `[결제] {거래처명} {금액}원` |
| `delivery` | 납기 | cyan (청록) | `delivery_{id}` | `[납기] {거래처명} {현장명 or 수주번호}` |
| `shipment` | 출고 | teal (틸) | `shipment_{id}` | `[출고] {거래처명} {현장명 or 출하번호}` |
## 제외 항목
| 항목 | 사유 |
|------|------|
| 미수금 입금 예정일 | `Deposit` 모델에 expected_date 필드 없음 → Phase 2 |
| 세금 납부 예정일 | 이미 CalendarScheduleStore + 상수로 orange 색상 표시 중 |
---
## 변경 파일
### Backend (1파일)
**`app/Services/CalendarService.php`**
- import 추가: `Order`, `ExpectedExpense`, `Shipment`
- `getSchedules()`: 3개 merge 블록 추가 (`expected_expense`, `delivery`, `shipment`)
- 신규 private 메서드 3개:
- `getExpectedExpenseSchedules()``ExpectedExpense` 모델, `expected_payment_date`, `payment_status != 'paid'`
- `getDeliverySchedules()``Order` 모델, `delivery_date`, 활성 status_code 5개
- `getShipmentSchedules()``Shipment` 모델, `scheduled_date`, status in ('scheduled', 'ready')
### Frontend (3파일)
**`src/components/business/CEODashboard/types.ts`**
- `CalendarScheduleItem.type` union에 3개 타입 추가
- `CalendarTaskFilterType` union에 3개 타입 추가
**`src/lib/api/dashboard/types.ts`**
- `CalendarScheduleType` union에 3개 타입 추가
**`src/components/business/CEODashboard/sections/CalendarSection.tsx`**
- `SCHEDULE_TYPE_COLORS`: rose/cyan/teal 추가
- `SCHEDULE_TYPE_ROUTES`: 3개 라우트 추가
- `SCHEDULE_TYPE_LABELS`: 결제예정/납기/출고 추가
- `SCHEDULE_TYPE_BADGE_COLORS`: rose/cyan/teal 뱃지 스타일 추가
- `TASK_FILTER_OPTIONS`: 필터 드롭다운 옵션 3개 추가
- `ExtendedTaskFilterType`: `'bill'` 제거 (CalendarTaskFilterType에 이미 포함)
- `getScheduleLink()`: `expected_expense`는 목록 페이지만 이동 (상세 없음)
- 모바일 `colorMap`: 3개 dot 색상 추가
---
## 라우트 매핑
| 타입 | 상세보기 클릭 시 이동 경로 | 비고 |
|------|--------------------------|------|
| `expected_expense` | `/ko/accounting/expected-expenses` | 목록 페이지 (상세 없음) |
| `delivery` | `/ko/sales/order-management-sales/{id}` | 수주 상세 |
| `shipment` | `/ko/outbound/shipments/{id}` | 출고 상세 |
---
## 검수 결과 (2026-03-10)
- [x] 캘린더 '전체' 필터에서 결제예정 항목 표시
- [x] 필터 드롭다운에 결제예정/납기/출고 옵션 추가
- [x] 결제예정 필터 선택 시 해당 타입만 표시
- [x] 결제예정 상세보기 링크 동작
- [x] 결제예정 뱃지 rose 색상 표시
- [x] 기존 5개 타입 정상 동작
- [x] TypeScript 빌드 에러 없음
- [ ] 납기/출고 데이터 표시 (테스트 DB에 해당 날짜 데이터 없어 미확인 — 기능은 정상)

View File

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

View File

@@ -0,0 +1,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

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

View File

@@ -0,0 +1,498 @@
# MES 데이터 정합성 심층 분석 보고서 v2
**분석일**: 2026-03-13 (v2 - 코드 업데이트 반영)
**범위**: 수주(Order) → 생산(Production) → 품질(Quality) → 출고(Shipment) 전체 파이프라인
**방법**: 프론트엔드(sam-react-prod) + 백엔드(sam-api) 코드 레벨 재분석
---
## v1 대비 변경사항 요약
| 항목 | v1 (초기 분석) | v2 (코드 업데이트 반영) |
|------|---------------|----------------------|
| StockLot.work_order_id FK | 확인 안됨 | ✅ 2026-02-21 추가 확인 (생산→재고 연결 기반 마련) |
| QualityDocument 시스템 | 존재 인지 | ✅ 2026-03-05~10 활발히 개선 중 (inspection_data, options JSON 추가) |
| 출하 자동생성 | 언급 | ✅ 상세 분석 완료: createShipmentFromOrder() 중복방지 + ensureShipmentExists() |
| 3월 MES FK 추가 | 미확인 | ❌ 3월 마이그레이션에 MES FK 추가 없음 확인 |
| 나머지 4개 이슈 | 발견 | 🔴 여전히 미해결 (can_ship, LOT, ShipmentItem FK) |
---
## Executive Summary
| # | 이슈 | 심각도 | v1 판정 | v2 판정 | 변경 |
|---|------|--------|---------|---------|------|
| 1 | 생산완료→수주 PRODUCED 자동전환 | 🟡→🟢 | 조건부 동작 | **정상 동작 + 자동출하 생성** | ⬆ 개선 |
| 2 | 품질검사 이중 시스템 | 🔴 | 구조적 문제 | 🔴 **구조적 문제 지속** (QualityDocument 활발 개발 중이나 출고 연동 미완) | 유지 |
| 3 | 출고 시 can_ship 검증 | 🔴 | 누락 | 🔴 **여전히 누락** (canProceedToShip 호출 0회) | 유지 |
| 4 | 출고 시 재고 차감 | ✅ | 구현됨 | ✅ **구현됨**, ⚠️ soft fail 리스크 유지 | 유지 |
| 5 | LOT 추적 체계 | 🔴 | 단절 | 🟡 **부분 개선** (StockLot.work_order_id FK 추가, 그러나 LOT 전달 로직 미구현) | ⬆ 부분 |
| 6 | 출고품목↔수주품목 FK | 🔴 | 없음 | 🔴 **여전히 없음** (3월 마이그레이션에도 미추가) | 유지 |
---
## 이슈 1: 생산완료 → 수주 상태 자동전환 + 출하 자동생성
### v2 판정: 🟢 정상 동작 (v1 대비 상향)
v2 분석에서 자동 출하 생성 로직까지 상세 확인 완료. **정상 동작 확인**.
### 전체 흐름
```
WorkOrder 상태 변경 (updateStatus)
syncOrderStatus() 자동 호출 (L971-1059)
메인 WO 필터링: is_auxiliary=false AND process_id≠null
전체 완료 시 → Order.status = PRODUCED
createShipmentFromOrder() 자동 호출 (L719-809)
Shipment 생성: status='scheduled', can_ship=true(자동)
기존 Shipment 있으면 → 중복 생성 방지 (L721-728)
```
### 코드 근거
**syncOrderStatus**: `WorkOrderService.php:971-1059`
```php
// L989-995: 메인 WO 필터 (보조공정 + process_id=null 제외)
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) =>
!$this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null
);
// L1001-1019: 상태 결정
if ($shippedCount === $totalCount) {
$newOrderStatus = Order::STATUS_SHIPPED;
} elseif (($completedCount + $shippedCount) === $totalCount) {
$newOrderStatus = Order::STATUS_PRODUCED;
}
```
**createShipmentFromOrder**: `WorkOrderService.php:719-809`
```php
// L721-728: 중복 방지
$existingShipment = Shipment::where('order_id', $order->id)->first();
if ($existingShipment) return $existingShipment;
// L732-744: 출하 자동 생성
$shipment = Shipment::create([
'order_id' => $order->id,
'work_order_id' => null, // 수주 레벨 (WO 레벨 아님)
'status' => 'scheduled',
'can_ship' => true, // ← 자동으로 true 설정
]);
// L746-790: WO 아이템 → ShipmentItem 복사
```
**ensureShipmentExists**: 이미 PRODUCED인데 출하가 없는 경우 보완 (L1027-1033)
### 잔존 리스크 (낮음)
| 조건 | 원인 | 발생 가능성 |
|------|------|------------|
| `process_id = NULL`인 WO | 공정 매핑 실패 | 낮음 (생성 시 검증됨) |
| `is_auxiliary` 오설정 | options JSON 수동 수정 | 매우 낮음 |
### 회의 논의 포인트
- ✅ 이 부분은 정상 동작 확인됨. 추가 조치 불필요
- (선택) process_id=null WO가 실데이터에 존재하는지 한번 쿼리 확인
---
## 이슈 2: 품질검사 이중 시스템
### v2 판정: 🔴 구조적 문제 지속 (QualityDocument 활발 개발 중이나 출고 연동은 미완)
### v1 대비 변화
| 변경 사항 | 시기 | 내용 |
|-----------|------|------|
| `quality_document_locations.inspection_data` JSON 추가 | 2026-03-06 | 개소별 검사 데이터 저장 |
| `quality_document_locations.options` JSON 추가 | 2026-03-10 | 검사 옵션 확장 |
| QualityDocumentService 개선 | 2026-03 | inspectLocation() 등 기능 확장 |
### 여전히 해결 안 된 핵심 문제
```
QualityDocument.complete() 호출 시:
→ inspection_status = 'completed' (QualityDocument 내부만 업데이트)
→ ❌ Shipment.can_ship 업데이트 없음
→ ❌ Inspection 테이블 동기화 없음
```
**두 시스템 현재 상태**:
| 항목 | 경로A: Inspection | 경로B: QualityDocument |
|------|-------------------|----------------------|
| **테이블** | `inspections` | `quality_documents` + `quality_document_locations` |
| **FK 연결** | `work_order_id` (작업지시) | `order_id` (수주) + `order_item_id` (개소) |
| **3월 업데이트** | 변경 없음 | ✅ 활발히 개선 중 |
| **출고 참조** | ❌ 안됨 | ❌ 안됨 |
### 회의 논의 포인트
- QualityDocument가 활발히 개발 중 → **경로B를 표준으로 확정하는 것이 합리적**
- 품질 완료 시 Shipment.can_ship 자동 업데이트 연동 필요
- 경로A(Inspection)는 IQC/PQC 전용으로 역할 한정, FQC는 경로B로 통일
---
## 이슈 3: 출고 시 can_ship 검증 누락
### v2 판정: 🔴 여전히 미해결 (canProceedToShip() 호출 0회 확인)
### 코드 현황 (변경 없음)
**canProceedToShip()**: `Shipment.php:220-223` — 정의만 존재
```php
public function canProceedToShip(): bool {
return $this->can_ship && $this->deposit_confirmed;
}
// grep 결과: 모델 정의 외 호출 0회
```
**updateStatus()**: `ShipmentService.php:305-356` — can_ship 검증 없이 바로 업데이트
```php
public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment
{
$shipment = Shipment::findOrFail($id);
// 🔴 can_ship 검증 없음
$shipment->update(['status' => $status, ...]);
}
```
**프론트엔드**: `ShipmentDetail.tsx:304-314` — can_ship 무시하고 버튼 표시
```typescript
const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
scheduled: 'ready', ready: 'shipping', shipping: 'completed', completed: null,
};
// can_ship=false여도 상태 변경 버튼 표시됨
```
### v2 신규 발견: 자동 출하에서 can_ship=true 자동 설정
```php
// createShipmentFromOrder (L732-744)
'can_ship' => true, // 자동 생성 시 무조건 true
```
→ 자동 생성된 출하는 can_ship=true이므로 문제 경감
**그러나** 수동 생성 출하에서는 여전히 검증 없음
### 위험 시나리오
```
수동 출하 생성 (can_ship=false)
→ 사용자가 "출하대기" 클릭 → 검증 없이 ready
→ "배송중" → "배송완료" → 재고 차감 시도
→ 재고 부족 시 soft fail (로그만, 상태는 completed) ❌
```
### 수정안 (최소 변경)
**백엔드** (1곳 수정):
```php
// ShipmentService::updateStatus() 시작부에 추가
if (in_array($status, ['ready', 'shipping', 'completed']) && !$shipment->can_ship) {
throw new \Exception('출하 불가 상태입니다. 품질 검수를 완료해주세요.');
}
```
**프론트엔드** (1곳 수정):
```typescript
// ShipmentDetail.tsx 버튼 표시 조건
{STATUS_TRANSITIONS[detail.status] && detail.canShip && (
<Button onClick={handleOpenStatusDialog}>변경</Button>
)}
```
---
## 이슈 4: 출고 시 재고 차감
### v2 판정: ✅ 구현됨, ⚠️ Soft Fail 리스크 유지 (변경 없음)
**코드**: `ShipmentService.php:361-401`
```php
private function decreaseStockForShipment(Shipment $shipment): void
{
foreach ($items as $item) {
try {
$stockService->decreaseForShipment(...);
} catch (\Exception $e) {
// 🟡 SOFT FAIL: 로그만 기록, 출하 상태는 completed 유지
Log::warning('Failed to decrease stock', [...]);
// throw 없음 → 다음 아이템으로 계속
}
}
}
```
### 회의 논의 포인트
- **Hard Fail 전환 여부**: `throw`로 변경하면 하나라도 실패 시 출하 전체 롤백
- **현재 방식 장점**: 일부 품목 재고 부족해도 출하는 진행 가능
- **권장**: 최소한 재고 차감 실패 건수를 프론트에 표시 + 관리자 알림
---
## 이슈 5: LOT 추적 체계
### v2 판정: 🟡 부분 개선 (v1 🔴 → v2 🟡)
### v1 대비 개선 사항
| 개선 | 시기 | 코드 근거 |
|------|------|-----------|
| `stock_lots.work_order_id` FK 추가 | 2026-02-21 | 마이그레이션 확인 |
| `inspections.work_order_id` FK 추가 | 2026-02-27 | 마이그레이션 확인 |
→ 재고↔생산, 검사↔생산 연결 **기반은 마련됨**
### 여전히 해결 안 된 핵심 문제
**1. 프론트에서 LOT 생성 → 백엔드 전송 안 됨**
```typescript
// WorkerScreen/actions.ts:246 — 프론트에서만 LOT 생성
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
// ← 이 값이 API 요청 body에 포함되지 않음
```
**2. 백엔드 LOT 저장 로직 없음**
```php
// WorkOrderService.php:578-583
case WorkOrder::STATUS_COMPLETED:
$workOrder->completed_at = now();
$this->saveItemResults($workOrder, $resultData, $userId);
// ❌ LOT 자동 채번/저장 로직 없음
break;
```
**3. 생산입고 시 LOT 전달 실패**
```php
// WorkOrderService.php:620-637
private function stockInFromProduction(WorkOrder $workOrder): void {
foreach ($workOrder->items as $woItem) {
$lotNo = $woItem->options['result']['lot_no'] ?? ''; // ← 항상 빈값
if ($goodQty > 0 && $lotNo) { // ← 조건 불충족 → 실행 안됨
$this->stockService->increaseFromProduction(...);
}
}
}
```
**StockLot.work_order_id FK는 추가됐지만, 실제 LOT를 생성/저장하는 코드가 없어서 FK가 활용되지 않음**
### LOT 추적 현황 (업데이트)
```
수주 KD-TS-260313-01
→ 생산 완료 (LOT 미생성 ❌ — 프론트에서만 생성, 백엔드 저장 안됨)
→ 재고 입고 (LOT 전달 실패 ❌ — stockInFromProduction 조건 불충족)
→ [신규] StockLot.work_order_id FK 존재 (✅ 기반 마련)
→ 품질검사 (별도 LOT 입력 ⚠️)
→ 출고 (자재 LOT만 선택 가능 ❌, 생산 LOT 없음)
```
### 수정 방향 (StockLot.work_order_id 활용)
```php
// 1. 백엔드에서 LOT 자동 채번 (WorkOrderService)
$lotNo = $this->numberingService->generate('production-lot', $tenantId);
// 2. saveItemResults()에서 lot_no 저장
$woItem->options = array_merge($woItem->options, ['result' => ['lot_no' => $lotNo]]);
// 3. stockInFromProduction()에서 정상 동작 → StockLot 생성 시 work_order_id 연결
$this->stockService->increaseFromProduction(
lotNo: $lotNo,
workOrderId: $workOrder->id // ← 이미 FK 존재
);
```
---
## 이슈 6: 출고품목 ↔ 수주품목 FK 부재
### v2 판정: 🔴 여전히 미해결 (3월 마이그레이션에도 미추가 확인)
### ShipmentItem 실제 컬럼 (변경 없음)
```
id, tenant_id, shipment_id(FK), seq,
item_code, item_name, floor_unit, specification,
quantity, unit, lot_no, stock_lot_id(index only), remarks
```
-`order_item_id` → 없음
-`work_order_item_id` → 없음
### 3월 마이그레이션 확인 결과
3월에 추가된 마이그레이션 중 `shipment_items` 관련 변경 **0건**.
주요 3월 마이그레이션은 QualityDocument 관련 (`inspection_data`, `options` JSON 추가)에 집중.
### 추적 불가 질문들 (여전히)
| 질문 | 답변 가능 여부 |
|------|--------------|
| "출고 #1234에서 어떤 수주 품목이 출고됐나?" | ❌ 불가 |
| "수주 품목 #999는 어느 출고에서 출고됐나?" | ❌ 역추적 불가 |
| "수주 10개 품목 중 미출고 품목은?" | ❌ 집계 불가 |
| "부분 출고 진행률은?" | ❌ 계산 불가 |
### 자동 출하 생성 시 연결 기회 놓침
```php
// createShipmentFromOrder (L746-790)
// WO 아이템을 ShipmentItem으로 복사하면서 source 정보 저장 안 함
ShipmentItem::create([
'shipment_id' => $shipment->id,
'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null,
'quantity' => $result['good_qty'] ?? $woItem->quantity,
// ❌ 'order_item_id' => $woItem->source_order_item_id ← 이것만 추가하면 됨
// ❌ 'work_order_item_id' => $woItem->id ← 이것만 추가하면 됨
]);
```
### 수정안
**마이그레이션** (새 파일):
```php
Schema::table('shipment_items', function (Blueprint $table) {
$table->unsignedBigInteger('order_item_id')->nullable()->after('stock_lot_id');
$table->unsignedBigInteger('work_order_item_id')->nullable()->after('order_item_id');
$table->foreign('order_item_id')->references('id')->on('order_items')->nullOnDelete();
$table->foreign('work_order_item_id')->references('id')->on('work_order_items')->nullOnDelete();
});
```
**createShipmentFromOrder** (2줄 추가):
```php
ShipmentItem::create([
...기존 필드,
'order_item_id' => $woItem->source_order_item_id, // 추가
'work_order_item_id' => $woItem->id, // 추가
]);
```
---
## 전체 FK 연결 현황도 (v2 업데이트)
```
orders ──────────────────── order_items ──────── order_nodes
│ (order_id FK) │ (order_node_id FK) │
│ │ │
├─── work_orders │ │
│ │ (sales_order_id FK) │ │
│ │ │ │
│ └─── work_order_items │ │
│ │ │ │
│ │ source_order_item_id ──→ ❌ FK 없음 (인덱스만)
│ │ │
│ inspections │
│ │ (work_order_id FK ✅) [2026-02-27 추가] │
│ │ (lot_no ← 연결 안됨 ❌) │
│ │
│ stock_lots │
│ │ (work_order_id FK ✅) [2026-02-21 추가] ← 🆕 v1에서 미확인
│ │
├─── quality_document_orders ──→ quality_documents │
│ │ (order_id FK ✅) │
│ │ │
│ └─── quality_document_locations │
│ │ (order_item_id FK ✅) │
│ │ (inspection_data JSON 🆕 2026-03-06) │
│ │ (options JSON 🆕 2026-03-10) │
│ │
└─── shipments │
│ (order_id FK ✅, work_order_id FK ✅) │
│ │
└─── shipment_items │
│ (shipment_id FK ✅) │
│ (stock_lot_id → 인덱스만, FK 없음) │
│ (order_item_id ❌ 컬럼 없음) │
│ (work_order_item_id ❌ 컬럼 없음) │
```
---
## 개선 우선순위 로드맵 (v2 업데이트)
### P0 (즉시 - 운영 리스크) — 변경 없음
| # | 작업 | 수정 범위 | 난이도 |
|---|------|---------|--------|
| 1 | **can_ship 검증 추가** | ShipmentService::updateStatus() 1곳 + ShipmentDetail.tsx 1곳 | 하 (수정 3줄) |
| 2 | **재고 차감 실패 알림** | ShipmentService::decreaseStockForShipment() → 최소 결과 반환 | 하 |
### P1 (단기 - 데이터 정합성) — 🆕 StockLot.work_order_id 활용 추가
| # | 작업 | 수정 범위 | 난이도 |
|---|------|---------|--------|
| 3 | **생산 LOT 백엔드 자동 채번** | WorkOrderService::saveItemResults() + NumberingService | 중 |
| 4 | **생산입고 LOT 연결** | WorkOrderService::stockInFromProduction() → StockLot.work_order_id 활용 | 중 |
| 5 | **shipment_items에 order_item_id 추가** | 마이그레이션 + createShipmentFromOrder() 2줄 추가 | 중 |
### P2 (중기 - 구조 개선) — 🆕 QualityDocument 기반 통합 명시
| # | 작업 | 수정 범위 | 난이도 |
|---|------|---------|--------|
| 6 | **품질검사 정본 = QualityDocument** | Inspection은 IQC/PQC 전용, FQC는 QualityDocument로 통일 | 상 |
| 7 | **품질완료 → can_ship 자동 연동** | QualityDocumentService::complete() → Shipment.can_ship 업데이트 | 중 |
| 8 | **work_order_items.source_order_item_id FK** | 마이그레이션 1줄 | 하 |
| 9 | **stock_lot_id FK constraint 추가** | shipment_items 마이그레이션 | 하 |
---
## 정상 동작 확인 항목 (v2)
- ✅ 수주 → 생산지시 생성 (공정별 자동 분류)
- ✅ 작업지시 상태 관리 (유효 상태 전환 + auxiliary 필터링)
-**syncOrderStatus()**: 메인 WO 완료 → Order PRODUCED 자동 전환
-**createShipmentFromOrder()**: PRODUCED 전환 시 출하 자동 생성 (중복 방지 포함)
-**ensureShipmentExists()**: 이미 PRODUCED인데 출하 없는 경우 보완
- ✅ 자재 투입 재고 차감 (WorkOrderMaterialInput + StockService)
- ✅ 출고 완료 시 재고 차감 (FIFO + lockForUpdate + stock_transactions)
- ✅ 출고 완료 → 수주 상태 SHIPPED 자동 전환
- ✅ 매출 자동 생성 (sales_recognition 조건부)
- ✅ 수주 상태별 수정/삭제 제한
- ✅ 생산지시 되돌리기 (WorkOrder/Item/Result 삭제)
- 🆕 ✅ StockLot.work_order_id FK (생산→재고 연결 기반)
- 🆕 ✅ Inspection.work_order_id FK (검사→생산 연결)
---
## 회의 토론 안건 정리
### 즉시 결정 필요 (P0)
1. **can_ship 검증**: 백엔드 1줄 + 프론트 1줄 수정으로 해결 가능. 즉시 적용?
2. **재고 차감 실패 처리**: Hard fail(롤백) vs Soft fail(현행) + 알림 추가?
### 설계 방향 결정 필요 (P1)
3. **LOT 채번 규칙**: 생산 LOT 형식 결정 (현재 프론트: `KD-SA-YYMMDD-NN`)
4. **생산 LOT 생성 시점**: WO 완료 시? WO 생성 시? 첫 작업 보고 시?
5. **ShipmentItem FK**: 마이그레이션 타이밍 (기존 데이터 소급 매칭 필요?)
### 방향성 논의 (P2)
6. **품질 시스템 정본**: QualityDocument를 표준으로 확정하는 것에 이견 있는지?
7. **품질→출하 자동 연동**: 어떤 조건에서 can_ship=true로 전환할 것인지?
- 전체 개소(location) 검사 완료 시?
- 합격률 기준?
- 수동 최종 승인 필요?

View File

@@ -0,0 +1,421 @@
# MES 데이터 정합성 심층 분석 보고서
**분석일**: 2026-03-13
**범위**: 수주(Order) → 생산(Production) → 품질(Quality) → 출고(Shipment) 전체 파이프라인
**방법**: 프론트엔드(sam-react-prod) + 백엔드(sam-api) 코드 레벨 분석
---
## Executive Summary
| # | 이슈 | 심각도 | 현황 | 코드 근거 |
|---|------|--------|------|-----------|
| 1 | 생산완료→수주 PRODUCED 자동전환 | 🟡 조건부 동작 | 로직 있으나 edge case에서 실패 가능 | `WorkOrderService.php:974-1062` |
| 2 | 품질검사 이중 시스템 | 🔴 구조적 문제 | Inspection vs QualityDocument 분리, 출고 연동 없음 | 양쪽 모두 Shipment 참조 안함 |
| 3 | 출고 시 can_ship 검증 | 🔴 누락 | canProceedToShip() 정의만 있고 호출 0회 | `ShipmentService.php:305-356` |
| 4 | 출고 시 재고 차감 | ✅ 구현됨 | completed 전환 시 FIFO 자동 차감 | `ShipmentService.php:361-401` |
| 5 | LOT 추적 체계 | 🔴 단절 | 프론트에서만 LOT 생성, 백엔드 저장 안됨 | `WorkerScreen/actions.ts:246` |
| 6 | 출고품목↔수주품목 FK | 🔴 없음 | ShipmentItem에 order_item_id 컬럼 자체 부재 | `shipment_items 마이그레이션` |
---
## 이슈 1: 생산완료 → 수주 상태 자동전환
### 결론: ✅ 로직 있음, 🟡 조건부 실패 가능
### 동작 원리
```
WorkOrder 상태 변경 (updateStatus)
↓ (라인 603)
syncOrderStatus() 자동 호출
↓ (라인 1004-1022)
메인 작업지시 집계 → 조건 충족 시 Order.status = PRODUCED
↓ (라인 1059-1061)
PRODUCED 전환 시 → 출고(Shipment) 자동 생성
```
**코드**: `sam-api/app/Services/WorkOrderService.php`
```php
// 라인 998: 메인 작업지시 필터
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) =>
!$this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null
);
// 라인 1011-1022: 상태 결정
if ($shippedCount === $totalCount) {
$newOrderStatus = Order::STATUS_SHIPPED;
} elseif (($completedCount + $shippedCount) === $totalCount) {
$newOrderStatus = Order::STATUS_PRODUCED; // ← 핵심 조건
} elseif ($inProgressCount > 0 || $completedCount > 0 || $shippedCount > 0) {
$newOrderStatus = Order::STATUS_IN_PRODUCTION;
}
```
### 실패 가능 조건
| 조건 | 원인 | 영향 |
|------|------|------|
| `process_id = NULL`인 WO 존재 | 공정 매핑 실패로 생성된 작업지시 | 메인 WO 카운트에서 제외 → 조건식 계산 오류 |
| `is_auxiliary = true` 오설정 | options JSON에 잘못 저장 | 메인 WO로 인식 안 됨 |
### 검증 SQL
```sql
-- 해당 수주의 작업지시 현황 확인
SELECT id, work_order_no, status, process_id,
JSON_EXTRACT(options, '$.is_auxiliary') as is_auxiliary
FROM work_orders
WHERE sales_order_id = {order_id} AND status != 'cancelled';
```
### 회의 논의 포인트
- process_id=null인 작업지시가 실제로 존재하는지 DB 확인 필요
- 존재한다면 → 생산지시 생성 시 process_id null 방지 로직 추가
---
## 이슈 2: 품질검사 이중 시스템
### 결론: 🔴 두 시스템이 독립 운영, 출고와 연동 없음
### 두 시스템 비교
| 항목 | 경로A: Inspection | 경로B: QualityDocument |
|------|-------------------|----------------------|
| **테이블** | `inspections` | `quality_documents` + `quality_document_locations` |
| **생성일** | 2025-12-29 | 2026-03-05 (최근 추가) |
| **연결 키** | `work_order_id` (작업지시) | `order_id` (수주) + `order_item_id` (개소) |
| **판정 필드** | `result: pass/fail` | `inspection_status: pending/completed` |
| **검사 단위** | 전체 건 | 개소(location)별 |
| **프론트 진입점** | 검사 메뉴 | 제품검사 메뉴 |
| **FQC 문서** | JSON items 배열 | Document 시스템 (EAV) |
| **출고 참조** | ❌ 안됨 | ❌ 안됨 |
### 핵심 문제: 출고에서 둘 다 참조 안함
**코드**: `sam-api/app/Services/ShipmentService.php`
```php
// 라인 207: 출고 생성 시
'can_ship' => $data['can_ship'] ?? false, // ← 수동 입력만, 품질 검사 결과 참조 없음
// 라인 220-223: 출고 가능 여부 메서드
public function canProceedToShip(): bool {
return $this->can_ship && $this->deposit_confirmed;
// ❌ Inspection.result 참조 없음
// ❌ QualityDocumentLocation.inspection_status 참조 없음
}
```
### 프론트엔드 판정 우선순위
**코드**: `src/components/quality/InspectionManagement/InspectionDetail.tsx`
```
경로B (QualityDocument/FQC 문서) 우선 → 경로A (Inspection) fallback
```
### 회의 논의 포인트
- **정본 결정 필요**: 경로A(Inspection) vs 경로B(QualityDocument) 중 하나를 표준으로
- 경로B가 최근(3월) 추가된 것 → 경로B를 표준으로 하고 경로A는 호환 레이어?
- 출고 시 품질 판정 자동 참조 로직 추가 필수
---
## 이슈 3: 출고 시 can_ship 검증 누락
### 결론: 🔴 canProceedToShip() 메서드 정의만 있고, 실제 호출 0회
### 현재 상태 변경 코드
**코드**: `sam-api/app/Services/ShipmentService.php:305-356`
```php
public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment
{
$shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id);
// 🔴 can_ship 검증 로직 전혀 없음
$shipment->update(['status' => $status, ...]); // ← 바로 업데이트
// completed 시 재고 차감 (이것은 동작함)
if ($status === 'completed' && $previousStatus !== 'completed') {
$this->decreaseStockForShipment($shipment);
}
}
```
### 프론트엔드도 미검증
**코드**: `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx:304-314`
```typescript
// 상태 전이 맵만 확인, canShip 체크 없음
const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
scheduled: 'ready',
ready: 'shipping',
shipping: 'completed',
completed: null,
};
// can_ship=false여도 버튼이 표시됨 ❌
{STATUS_TRANSITIONS[detail.status] && (
<Button onClick={handleOpenStatusDialog}>변경</Button>
)}
```
### 위험 시나리오
```
can_ship=false (품질 미통과) + status=scheduled
→ 사용자가 "출하대기로 변경" 클릭
→ 백엔드 검증 없음 → status='ready' ❌
→ "배송중" → "배송완료" → 재고 차감 시도
→ 재고 부족 시 soft fail (로그만 기록, 상태는 변경됨) ❌
```
### 회의 논의 포인트
- 백엔드: `updateStatus()``can_ship` 검증 추가 (1줄 수정)
- 프론트: 버튼 표시 조건에 `detail.canShip` 추가
- 재고 차감 실패 시 hard fail로 변경할지 논의 필요
---
## 이슈 4: 출고 시 재고 차감
### 결론: ✅ 완전 구현됨
**코드**: `sam-api/app/Services/ShipmentService.php:347-350`
```php
if ($status === 'completed' && $previousStatus !== 'completed') {
$this->decreaseStockForShipment($shipment);
}
```
**StockService FIFO 차감**: `StockService.php:1236-1354`
- Stock 행 잠금 (lockForUpdate)
- LOT별 FIFO 순서 차감
- stock_transactions 거래 기록 (reason: SHIPMENT)
- 감사 로그 기록
**⚠️ 주의**: 개별 품목 차감 실패 시 soft fail (로그만 기록, 트랜잭션 미롤백)
---
## 이슈 5: LOT 추적 체계 단절
### 결론: 🔴 4개 모듈이 완전 독립적 LOT 관리, 추적 불가
### LOT 생성/관리 현황
| 모듈 | LOT 형식 | 생성 위치 | 저장 위치 | 상태 |
|------|----------|-----------|-----------|------|
| **수주** | - | - | Order에 lot_no 필드 없음 | ❌ 필드 없음 |
| **생산** | `KD-SA-YYMMDD-NN` | 프론트 `WorkerScreen/actions.ts:246` | ❌ 백엔드 전송 안됨 | ❌ 저장 안됨 |
| **자재** | 입고 시 생성 | `StockService` | `stock_lots.lot_no` | ✅ 동작 |
| **품질** | 검사팀 별도 입력 | `InspectionService` | `inspections.lot_no` | ⚠️ 연결 없음 |
| **출고** | StockLot에서 선택 | `ShipmentService:getLotOptions()` | `shipments.lot_no` | ⚠️ 자재 LOT만 |
### 핵심 단절 코드
**프론트에서 LOT 생성하지만 전송 안 함**:
```typescript
// WorkerScreen/actions.ts:246
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
// ← 이 값이 API 요청에 포함되지 않음
```
**백엔드에서 LOT 저장 안 함**:
```php
// WorkOrderService.php:578-583
case WorkOrder::STATUS_COMPLETED:
$workOrder->completed_at = now();
$this->saveItemResults($workOrder, $resultData, $userId);
// ❌ LOT 생성/저장 로직 없음
break;
```
**생산입고 시 LOT 전달 실패**:
```php
// WorkOrderService.php:620-637
private function stockInFromProduction(WorkOrder $workOrder): void {
foreach ($workOrder->items as $woItem) {
$lotNo = $woItem->options['result']['lot_no'] ?? ''; // ← 항상 빈값
if ($goodQty > 0 && $lotNo) { // ← 조건 불충족으로 실행 안됨
$this->stockService->increaseFromProduction(...);
}
}
}
```
**출고 LOT 옵션에서 생산 LOT 제외**:
```php
// ShipmentService.php:525-550
public function getLotOptions(): array {
return StockLot::where(...) // ← 구매입고 LOT만 조회
->whereIn('status', ['available', 'reserved'])
->get();
// ❌ 생산 완료 LOT(KD-SA-*) 미포함
}
```
### 추적 불가 시나리오
```
수주 KD-TS-260313-01
→ 생산 완료 (LOT 미생성)
→ 재고 입고 (LOT 전달 실패 → 입고 안됨?)
→ 품질검사 (별도 LOT 입력)
→ 출고 (자재 LOT만 선택 가능, 생산품 LOT 없음)
결과: "이 출고 건이 어느 생산 LOT인지" → 답 불가
```
### 회의 논의 포인트
- **최우선**: 백엔드에서 생산 LOT 자동 채번/저장 로직 구현
- WorkResult.lot_no에 실제 저장
- StockLot.work_order_id (이미 2026-02-21 추가됨) 활용하여 연결
- getLotOptions()에 생산 LOT 포함
---
## 이슈 6: 출고품목 ↔ 수주품목 FK 부재
### 결론: 🔴 ShipmentItem에 order_item_id, work_order_item_id 컬럼 자체가 없음
### ShipmentItem 실제 컬럼
**마이그레이션**: `2025_12_26_150605_create_shipment_items_table.php`
```
id, tenant_id, shipment_id(FK), seq,
item_code, item_name, floor_unit, specification,
quantity, unit, lot_no, stock_lot_id(FK), remarks
```
-`order_item_id` → 없음
-`work_order_item_id` → 없음
- 품목 데이터는 **텍스트 복사**만 (품명, 규격, 수량)
### ShipmentItem 생성 코드
**코드**: `sam-api/app/Services/ShipmentService.php:468-493`
```php
ShipmentItem::create([
'item_code' => $item['item_code'] ?? null,
'item_name' => $item['item_name'],
'quantity' => $item['quantity'] ?? 0,
// ❌ order_item_id 없음
// ❌ work_order_item_id 없음
]);
```
### 추적 불가 질문들
| 질문 | 답변 가능 여부 |
|------|--------------|
| "출고 #1234에서 어떤 수주 품목이 출고됐나?" | ❌ 불가 |
| "수주 품목 #999는 어느 출고에서 출고됐나?" | ❌ 역추적 불가 |
| "수주 10개 품목 중 미출고 품목은?" | ❌ 집계 불가 |
| "부분 출고 진행률은?" | ❌ 계산 불가 |
### 관련 FK도 불완전
**WorkOrderItem.source_order_item_id**: 인덱스만 있고 FK constraint 없음
```php
// 마이그레이션 2026_01_16
$table->unsignedBigInteger('source_order_item_id')->nullable();
$table->index('source_order_item_id'); // ← 인덱스만
// ❌ $table->foreign('source_order_item_id')->references('id')->on('order_items') 없음
```
### 회의 논의 포인트
- shipment_items에 `order_item_id`, `work_order_item_id` 컬럼 추가 마이그레이션
- 기존 데이터 마이그레이션 방안 (품명+규격으로 매칭?)
- work_order_items.source_order_item_id에 FK constraint 추가
---
## 전체 FK 연결 현황도
```
orders ──────────────────── order_items ──────── order_nodes
│ (order_id FK) │ (order_node_id FK) │
│ │ │
├─── work_orders │ │
│ │ (sales_order_id FK) │ │
│ │ │ │
│ └─── work_order_items │ │
│ │ │ │
│ │ source_order_item_id ──→ ❌ FK 없음 (인덱스만)
│ │ │
│ inspections │
│ │ (work_order_id FK ✅) │
│ │ (lot_no ← 연결 안됨 ❌) │
│ │
├─── quality_document_orders ──→ quality_documents │
│ │ (order_id FK ✅) │
│ │ │
│ └─── quality_document_locations │
│ │ (order_item_id FK ✅) │
│ │
└─── shipments │
│ (order_id FK ✅, work_order_id FK ✅) │
│ │
└─── shipment_items │
│ (shipment_id FK ✅) │
│ (stock_lot_id FK ✅) │
│ (order_item_id ❌ 없음) │
│ (work_order_item_id ❌ 없음) │
```
---
## 개선 우선순위 로드맵
### P0 (즉시 - 운영 리스크)
| # | 작업 | 영향범위 | 예상 난이도 |
|---|------|---------|------------|
| 1 | **can_ship 검증 추가** (백엔드 updateStatus + 프론트 버튼 조건) | ShipmentService 1곳 + ShipmentDetail 1곳 | 하 |
| 2 | **재고 차감 실패 시 hard fail** (try-catch에서 throw로 변경) | ShipmentService 1곳 | 하 |
### P1 (단기 - 데이터 정합성)
| # | 작업 | 영향범위 | 예상 난이도 |
|---|------|---------|------------|
| 3 | **생산 LOT 백엔드 자동 채번/저장** | WorkOrderService + NumberingService | 중 |
| 4 | **생산입고 LOT 연결 수정** (stockInFromProduction) | WorkOrderService + StockService | 중 |
| 5 | **shipment_items에 order_item_id 추가** (마이그레이션 + 서비스) | 마이그레이션 + ShipmentService | 중 |
### P2 (중기 - 구조 개선)
| # | 작업 | 영향범위 | 예상 난이도 |
|---|------|---------|------------|
| 6 | **품질검사 정본 결정** (Inspection vs QualityDocument 통합) | 양쪽 서비스 + 프론트 | 상 |
| 7 | **출고 시 품질 판정 자동 참조** (can_ship 자동 설정) | ShipmentService + 품질 연동 | 상 |
| 8 | **work_order_items.source_order_item_id FK 추가** | 마이그레이션 | 하 |
| 9 | **process_id=null 작업지시 생성 방지** | OrderService.createProductionOrder | 하 |
---
## 참고: 정상 동작하는 부분
- ✅ 수주 → 생산지시 생성 (공정별 자동 분류)
- ✅ 작업지시 상태 관리 (유효한 상태 전환 규칙)
- ✅ 자재 투입 재고 차감 (WorkOrderMaterialInput + StockService)
- ✅ 출고 완료 시 재고 차감 (FIFO + 거래 기록)
- ✅ 출고 완료 → 수주 상태 SHIPPED 자동 전환
- ✅ 매출 자동 생성 (sales_recognition 조건부)
- ✅ 수주 상태별 수정/삭제 제한
- ✅ 생산지시 되돌리기 (WorkOrder/Item/Result 삭제)

View File

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

View File

@@ -0,0 +1,894 @@
# 동적 멀티테넌트 페이지 시스템 설계
> 작성일: 2026-03-11
> 상태: 초안 (백엔드 논의 필요)
> 관련 문서:
> - `[VISION-2026-02-19] dynamic-rendering-platform-strategy.md`
> - `[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md`
> - `[DESIGN-2026-02-11] dynamic-field-type-extension.md`
---
## 1. 핵심 목표
```
현재: 테넌트(업종)별 페이지를 하드코딩 → 신규 테넌트마다 개발 필요
목표: 백엔드 기준관리에서 설정 → JSON API → 프론트 동적 렌더링
결과: 프론트엔드 코드 변경 0줄로 새 테넌트 대응
```
---
## 2. 전체 아키텍처
```
┌─────────────────────────────────────────────────────────┐
│ 백엔드 어드민 (mng) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ 기준관리 페이지 │ │
│ │ 레이아웃 / 섹션 / 항목 / 속성 등록 │ │
│ └───────────────┬───────────────────────────────────┘ │
│ │ 저장 │
│ ↓ │
│ ┌───────────────────────────────────────────────────┐ │
│ │ DB (테넌트별 페이지 config) │ │
│ └───────────────┬───────────────────────────────────┘ │
│ │ API │
└──────────────────┼──────────────────────────────────────┘
┌──────────────────────────────────────────────────────────┐
│ 프론트엔드 (Next.js) │
│ │
│ ┌────────────┐ ┌─────────────────────────────────┐ │
│ │ 정적 페이지 │ │ 동적 페이지 │ │
│ │ - 로그인 │ │ - catch-all route │ │
│ │ - 회원가입 │ │ - JSON config → 동적 렌더링 │ │
│ │ - 404 등 │ │ - pageType별 렌더러 선택 │ │
│ └────────────┘ └─────────────────────────────────┘ │
│ │ │ │
│ └──── 공유 컴포넌트 ───────┘ │
│ (ui/, molecules/, organisms/) │
└──────────────────────────────────────────────────────────┘
```
---
## 3. 규칙 정의
### 규칙 1: 기준관리 → 백엔드 어드민
| 항목 | 내용 |
|------|------|
| 현재 | 프론트 `ItemMasterDataManagement` 등에서 기준관리 |
| 변경 | 백엔드 어드민(mng) 페이지로 이동 |
| 이유 | 프론트 번들 크기 감소, 설정 변경 = 배포 불필요 |
| 담당 | 🔵 백엔드 |
```
Before: 프론트 기준관리 UI → 프론트 API 호출 → DB 저장
After: 백엔드 어드민 UI → 직접 DB 저장 → API로 config 전달
```
---
### 규칙 2: 페이지 정보를 JSON API로 제공
| 항목 | 내용 |
|------|------|
| 방식 | 메뉴 API처럼 페이지 config도 JSON API로 제공 |
| 엔드포인트 | `GET /api/v1/page-configs/{slug}` (제안) |
| 응답 | 페이지 타입, 레이아웃, 섹션, 필드, 검증규칙, API 매핑 등 |
| 담당 | 🔵 백엔드 API 설계 |
**페이지 config JSON 구조 (제안)**:
```jsonc
{
"pageId": "sales-order-list",
"pageType": "list", // list | detail | form | dashboard | document
"title": "수주 관리",
"slug": "sales/order-management",
// --- 규칙 11: API 엔드포인트 매핑 ---
"api": {
"list": "/api/v1/orders",
"detail": "/api/v1/orders/:id",
"create": "/api/v1/orders",
"update": "/api/v1/orders/:id",
"delete": "/api/v1/orders/:id"
},
// --- 규칙 4: 레이아웃 > 섹션 > 항목 > 속성 ---
"layout": {
"sections": [
{
"sectionId": "filters",
"sectionType": "filter",
"fields": [
{
"fieldId": "status",
"type": "select",
"label": "상태",
"options": [
{ "value": "all", "label": "전체" },
{ "value": "pending", "label": "대기" },
{ "value": "confirmed", "label": "확정" }
],
"defaultValue": "all"
},
{
"fieldId": "dateRange",
"type": "dateRange",
"label": "기간"
}
]
},
{
"sectionId": "table",
"sectionType": "dataTable",
"columns": [
{ "key": "orderNo", "label": "수주번호", "width": 120 },
{ "key": "clientName", "label": "거래처명", "width": 150 },
{ "key": "amount", "label": "금액", "type": "currency", "align": "right" },
{ "key": "status", "label": "상태", "type": "badge" }
],
"actions": ["view", "edit", "delete"],
"pagination": true
}
]
},
// --- 규칙 12: 검증 규칙 ---
"validation": {
"quantity": { "required": true, "min": 1, "message": "1 이상 입력하세요" },
"clientId": { "required": true, "message": "거래처를 선택하세요" }
},
// --- 규칙 13: 필드 간 의존성 ---
"dependencies": [
{
"type": "visibility",
"when": { "field": "itemType", "equals": "motor" },
"show": ["motorSpec", "voltage"]
},
{
"type": "computed",
"target": "amount",
"formula": "quantity * unitPrice"
},
{
"type": "cascade",
"source": "category1",
"target": "category2",
"api": "/api/v1/categories/:parentId/children"
}
],
// --- 규칙 14: 권한 ---
"permissions": {
"fieldLevel": {
"unitPrice": { "view": ["admin", "sales_manager"], "edit": ["admin"] }
},
"actionLevel": {
"delete": ["admin"],
"export": ["admin", "sales_manager"]
}
}
}
```
> ⚠️ **백엔드 논의 필요**: JSON 구조의 세부 스펙 확정
#### 2-2. 백엔드 저장 방식: JSONB (확정)
> ✅ **확정**: 페이지 config는 PostgreSQL **JSONB** 타입으로 저장
| 항목 | JSON | JSONB (채택) |
|------|------|:---:|
| 저장 형태 | 텍스트 그대로 | 바이너리 (파싱된 형태) |
| 읽기 속도 | 매번 파싱 필요 | 이미 파싱됨 → **빠름** |
| 인덱싱 | ❌ 불가 | ✅ **GIN 인덱스 가능** |
| 내부 검색 | ❌ 전체 꺼내서 비교 | ✅ **특정 키/값으로 쿼리** |
| 부분 수정 | ❌ 전체 교체 | ✅ **특정 키만 업데이트** |
**JSONB가 필요한 이유 — 우리 시스템과의 연관**:
```sql
-- 1. 테넌트별 특정 타입 페이지만 조회 (인덱싱)
SELECT * FROM page_configs
WHERE tenant_id = 282
AND config->>'pageType' = 'list';
-- 2. 특정 필드 타입을 쓰는 페이지 검색 (내부 검색)
SELECT * FROM page_configs
WHERE config @> '{"layout":{"sections":[{"fields":[{"type":"reference"}]}]}}';
-- 3. 기준관리에서 섹션 하나만 수정 (부분 수정)
UPDATE page_configs
SET config = jsonb_set(config, '{layout,sections,0,title}', '"수정된 섹션명"');
```
**JSONB 채택이 config 구조 설계에 미치는 영향**:
| 영향 | 설명 |
|------|------|
| **구조 단순화** | 하나의 큰 JSONB에 전체 config를 담아도 부분 쿼리/수정 가능 → 테이블 분리 최소화 |
| **테넌트 분기** | JSONB 인덱스로 테넌트+pageType 조합 쿼리가 빠름 → 별도 테이블 불필요 |
| **기준관리 UI** | 섹션 하나만 수정해도 전체 config를 다시 저장할 필요 없음 → UX 향상 |
| **프론트 영향** | **없음** — 프론트는 동일한 JSON을 받아서 렌더링, 저장 방식 무관 |
```
DB 테이블 구조 (제안):
page_configs
├── id (PK)
├── tenant_id (FK, 인덱스)
├── slug (UNIQUE per tenant, 인덱스)
├── config (JSONB) ← 페이지 config 전체
├── created_at
└── updated_at
GIN 인덱스: config에 대해 생성 → 내부 검색 고속화
복합 인덱스: (tenant_id, slug) → 테넌트별 페이지 조회 최적화
```
> ⚠️ **백엔드 논의 필요**: JSONB 기반 테이블 설계 세부 확정 (위 제안 구조 검토)
---
### 규칙 3: 정적 페이지 vs 동적 페이지 분류
| 분류 | 정적 페이지 | 동적 페이지 |
|------|------------|------------|
| 정의 | 테넌트 무관, 고정 UI | 테넌트 config 기반 동적 생성 |
| 예시 | 로그인, 회원가입, 404, 500 | 수주관리, 품목관리, 공정관리 등 |
| 라우팅 | 기존 파일 기반 라우트 | catch-all `[...slug]` |
| 컴포넌트 | 직접 코딩 | JSON → 동적 렌더러 |
| 변경 빈도 | 거의 없음 | 테넌트별/설정별 수시 변경 |
**정적 페이지 목록 (확정)**:
| 경로 | 페이지 | 이유 |
|------|--------|------|
| `/login` | 로그인 | 인증 전 접근, 공통 UI |
| `/signup` | 회원가입 | 인증 전 접근, 공통 UI |
| `/404` | Not Found | 에러 페이지 |
| `/500` | Server Error | 에러 페이지 |
| `/settings/*` | 설정 | 시스템 설정은 공통 |
> ⚠️ **논의 필요**: 설정 페이지 중 일부(구독, 결제)도 동적 대상인지?
> ⚠️ **논의 필요**: 대시보드는 동적 페이지? 위젯 기반 별도 시스템?
---
### 규칙 4: 계층 구조 — 레이아웃 > 섹션 > 항목 > 속성
```
Page (pageType에 의해 렌더러 결정)
└─ Layout (전체 레이아웃: single-column, two-column, tabs 등)
└─ Section (논리적 그룹: 기본정보, 상세정보, 테이블 등)
└─ Field (개별 입력 항목: input, select, date 등)
└─ Attribute (필드의 속성: label, placeholder, validation 등)
```
| 계층 | 역할 | 기준관리 등록 항목 | 프론트 컴포넌트 |
|------|------|-------------------|----------------|
| Layout | 전체 배치 | 레이아웃 타입 선택 | `DynamicPageLayout` |
| Section | 논리적 그룹 | 섹션 추가/순서/조건부 표시 | `DynamicSection` |
| Field | 개별 항목 | 필드 타입/라벨/기본값 | `DynamicFieldRenderer` (14종) |
| Attribute | 필드 속성 | 검증규칙/옵션/의존성 | props로 전달 |
---
### 규칙 5: 컴포넌트 책임 분리
```
┌─────────────────────────────────────────────────┐
│ 상위: 데이터 처리 컴포넌트 (Layout, Section) │
│ - API 호출 / 데이터 가공 │
│ - 조건부 표시 로직 │
│ - props 전달 / 이벤트 핸들링 │
│ - Zustand store 구독 │
└──────────────────┬──────────────────────────────┘
│ props (순수 데이터)
┌─────────────────────────────────────────────────┐
│ 하위: 순수 기능 컴포넌트 (Field, Attribute) │
│ - UI 렌더링만 담당 │
│ - 외부 의존성 없음 │
│ - value + onChange 패턴 │
│ - 테스트 용이 │
└─────────────────────────────────────────────────┘
```
| 구분 | 상위 (Layout/Section) | 하위 (Field/Attribute) |
|------|----------------------|----------------------|
| 역할 | 데이터 처리, 조건 분기 | 순수 렌더링 |
| 상태 | Zustand 구독 | props only |
| API | 호출 가능 | 호출 안 함 |
| 예시 | `DynamicSection`, `DynamicListPage` | `Input`, `Select`, `DatePicker` |
| 테스트 | 통합 테스트 | 단위 테스트 |
---
### 규칙 6: Zustand 기반 상태 관리
```
┌────────────────────────────────────────────────┐
│ pageConfigStore (Zustand) │
│ │
│ state: │
│ configs: Map<slug, PageConfig> │
│ currentPage: PageConfig | null │
│ loading: boolean │
│ │
│ actions: │
│ fetchPageConfig(slug) → API 호출 + 캐시 │
│ invalidateConfig(slug) → 캐시 무효화 │
│ subscribeToPage(slug) → 실시간 구독 │
└────────────────────────────────────────────────┘
│ 구독
┌────────────────┐ ┌────────────────┐
│ DynamicListPage │ │ DynamicFormPage │ ...
└────────────────┘ └────────────────┘
```
| 항목 | 설명 |
|------|------|
| Store 위치 | `src/stores/pageConfigStore.ts` (신규) |
| 캐시 전략 | 메모리(Zustand) → localStorage → API |
| 변경 감지 | 해시 비교 (메뉴 갱신과 동일 방식) |
| 테넌트 격리 | 기존 `TenantAwareCache` 패턴 재사용 |
---
### 규칙 7: 테넌트 + 하위 구성요소별 화면 분기
```
테넌트 A (셔터 제조업)
├─ 메뉴: 품목관리, 생산관리, 출하관리
├─ 품목 폼: 셔터 규격 필드 포함
└─ 생산 공정: 셔터 전용 공정 단계
테넌트 B (건설업)
├─ 메뉴: 프로젝트관리, 공사관리, 기성관리
├─ 프로젝트 폼: 현장정보 필드 포함
└─ 공사 공정: 건설 전용 단계
같은 테넌트 내에서도:
├─ 부서 A → 메뉴 5개, 필드 20개 표시
└─ 부서 B → 메뉴 3개, 필드 12개 표시
```
| 분기 기준 | 설명 | 예시 |
|----------|------|------|
| 테넌트 (company) | 업종별 전체 화면 구성 | 셔터업 vs 건설업 |
| 부서 (department) | 같은 테넌트 내 부서별 | 영업팀 vs 생산팀 |
| 역할 (role) | 같은 부서 내 역할별 | 관리자 vs 일반 |
| 사용자 (user) | 개인 설정 | 즐겨찾기, 컬럼 순서 |
> ⚠️ **백엔드 논의 필요**: 분기 우선순위 및 상속 정책
> (테넌트 설정 → 부서 설정으로 오버라이드 → 사용자 설정으로 오버라이드?)
---
### 규칙 8: 정적/동적 컴포넌트 공유
```
src/components/
├── ui/ ← 공유 (정적+동적 모두 사용)
│ ├── Input.tsx
│ ├── Select.tsx
│ ├── DatePicker.tsx
│ └── ...
├── molecules/ ← 공유
│ ├── FormField.tsx
│ ├── SearchFilter.tsx
│ └── ...
├── organisms/ ← 공유
│ ├── DataTable.tsx
│ ├── MobileCard.tsx
│ └── ...
├── dynamic/ ← 동적 전용 (신규)
│ ├── renderers/
│ │ ├── DynamicListPage.tsx
│ │ ├── DynamicDetailPage.tsx
│ │ ├── DynamicFormPage.tsx
│ │ └── DynamicDashboardPage.tsx
│ ├── sections/
│ │ ├── DynamicSection.tsx
│ │ ├── DynamicFilterSection.tsx
│ │ └── DynamicTableSection.tsx ← 기존 이동
│ ├── fields/
│ │ └── DynamicFieldRenderer.tsx ← 기존 이동 (14종)
│ └── store/
│ └── pageConfigStore.ts
└── static/ ← 정적 전용 (기존 유지)
├── auth/LoginPage.tsx
└── auth/SignupPage.tsx
```
| 레이어 | 공유 여부 | 예시 |
|--------|----------|------|
| ui/ | ✅ 100% 공유 | Input, Select, Button |
| molecules/ | ✅ 100% 공유 | FormField, StatusBadge |
| organisms/ | ✅ 대부분 공유 | DataTable, SearchFilter |
| dynamic/renderers/ | ❌ 동적 전용 | DynamicListPage |
| 기존 도메인 컴포넌트 | ❌ 정적 전용 (점진적 전환) | OrderSalesDetailEdit |
---
### 규칙 9: 페이지 타입 분류 체계
| pageType | 용도 | 핵심 구성 요소 | 기존 대응 패턴 |
|----------|------|--------------|---------------|
| `list` | 목록 조회 | 필터 + 테이블 + 페이지네이션 + 액션 | UniversalListPage |
| `detail` | 상세 보기 | 읽기전용 섹션 + 수정/삭제 버튼 | IntegratedDetailTemplate |
| `form` | 등록/수정 | 입력 섹션 + 저장/취소 | DynamicItemForm (범용화) |
| `dashboard` | 대시보드 | 위젯/카드 그리드 | CEODashboard |
| `document` | 문서/프린트 | 프린트 레이아웃 + 결재란 | ContractDocument 등 |
```
pageType 결정 흐름:
API 응답의 pageType 값
├─ "list" → <DynamicListPage config={...} />
├─ "detail" → <DynamicDetailPage config={...} />
├─ "form" → <DynamicFormPage config={...} />
├─ "dashboard" → <DynamicDashboardPage config={...} />
├─ "document" → <DynamicDocumentPage config={...} />
└─ 미지원 → <FallbackPage /> (에러 표시)
```
---
### 규칙 10: 동적 라우팅 전략
```
src/app/[locale]/(protected)/
├── (static-pages)/ ← 정적 페이지 그룹
│ ├── settings/
│ └── ...
└── [...slug]/ ← 동적 페이지 catch-all
└── page.tsx ← 아래 로직 수행
```
**catch-all page.tsx 동작 흐름**:
```
1. URL에서 slug 추출 (예: ["sales", "order-management"])
2. slug로 pageConfigStore에서 config 조회 (캐시 우선)
3. 캐시 없으면 → API 호출: GET /api/v1/page-configs/sales/order-management
4. config.pageType으로 렌더러 선택
5. 렌더러에 config 전달 → 동적 페이지 렌더링
```
| 라우트 우선순위 | 경로 | 설명 |
|---------------|------|------|
| 1 (최우선) | `/login`, `/signup` | 정적 페이지 (파일 존재) |
| 2 | `/settings/*` | 정적 그룹 (파일 존재) |
| 3 (폴백) | `/*` (나머지 전부) | catch-all → 동적 처리 |
> Next.js 라우팅 규칙: 구체적 경로 > catch-all → 충돌 없음
> ⚠️ **논의 필요**: 기존 정적 페이지를 동적으로 전환 시, 해당 파일 삭제 후 catch-all로 자연스럽게 이관
---
### 규칙 11: API 엔드포인트 동적 매핑
#### 11-1. API 호출 유형
| API 유형 | config 키 | 용도 |
|---------|----------|------|
| `list` | `api.list` | 목록 조회 (GET) |
| `detail` | `api.detail` | 상세 조회 (GET) |
| `create` | `api.create` | 등록 (POST) |
| `update` | `api.update` | 수정 (PUT/PATCH) |
| `delete` | `api.delete` | 삭제 (DELETE) |
| `export` | `api.export` | 엑셀 다운로드 (GET) |
| `custom` | `api.custom[actionName]` | 커스텀 액션 |
#### 11-2. 백엔드 API 제공 방식 (3가지 방향)
동적 페이지는 **데이터를 어디서 가져올지도 동적**이어야 합니다.
백엔드가 API를 어떤 방식으로 제공하느냐에 따라 3가지 방향이 있습니다.
| 방향 | 설명 | 장점 | 단점 |
|------|------|------|------|
| **A. 개별 API** | 페이지마다 전용 API 존재, config에 경로 명시 | 기존 API 재사용, 복잡한 로직 처리 가능 | 새 페이지마다 백엔드 개발 필요 |
| **B. 범용 Entity API** | 하나의 엔드포인트가 entityType으로 분기 | 새 페이지 추가 시 백엔드 코드 변경 없음 | 복잡한 비즈니스 로직 처리 어려움 |
| **C. 하이브리드 (권장)** | 단순 CRUD는 범용 API, 복잡한 로직은 전용 API | 양쪽 장점 모두 취함 | 두 방식 공존에 따른 관리 비용 |
**방향 A: 개별 API (config에 경로 포함)**
```jsonc
{
"pageType": "list",
"slug": "sales/order-management",
"api": {
"list": "/api/v1/orders",
"detail": "/api/v1/orders/:id",
"create": "/api/v1/orders",
"delete": "/api/v1/orders/:id"
}
}
```
→ 기존에 이미 만들어둔 API를 그대로 config에 연결
→ 견적 계산, 세금 처리 등 **비즈니스 로직이 있는 페이지에 적합**
**방향 B: 범용 Entity API**
```jsonc
{
"pageType": "list",
"slug": "master/equipment",
"entityType": "equipment"
}
```
```
// 범용 API 1개로 모든 entity 처리
GET /api/v1/entities/{entityType}
GET /api/v1/entities/{entityType}/{id}
POST /api/v1/entities/{entityType}
PUT /api/v1/entities/{entityType}/{id}
DELETE /api/v1/entities/{entityType}/{id}
```
→ 백엔드에서 entityType에 따라 테이블/모델 동적 매핑
→ 단순 CRUD(거래처, 설비, 자재 등) **마스터 데이터 페이지에 적합**
**방향 C: 하이브리드 (권장)**
```
┌──────────────────────────────────────────────────┐
│ 단순 CRUD 페이지 (거래처, 설비, 자재 등) │
│ → 방향 B: 범용 entity API │
│ → config에 entityType만 지정 │
│ → 새 페이지 추가 시 백엔드 코드 변경 없음 │
│ → 동적 시스템의 최대 효과 │
└──────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────┐
│ 비즈니스 로직 페이지 (견적, 생산, 세금계산서) │
│ → 방향 A: 전용 API 경로를 config에 명시 │
│ → 계산/검증/워크플로우 등 복잡한 로직 처리 │
│ → 기존 API 재사용으로 마이그레이션 용이 │
└──────────────────────────────────────────────────┘
```
#### 11-3. 프론트 처리 방식 (어느 방향이든 동일)
```
프론트는 API 제공 방식에 무관하게 동일한 패턴으로 처리:
1. config에서 API 경로 결정
├─ api.list 있으면 → 그 경로 사용 (방향 A)
└─ entityType 있으면 → `/api/v1/entities/${entityType}` 생성 (방향 B)
2. buildApiUrl(경로, params) ← 기존 유틸 재사용
3. Server Action에서 API 프록시 호출
4. 응답을 config.columns 기준으로 렌더링
```
```typescript
// 프론트 API 경로 결정 유틸 (예시)
function resolveApiUrl(config: PageConfig, action: 'list' | 'detail' | 'create' | 'update' | 'delete') {
// 방향 A: 전용 API 경로가 있으면 사용
if (config.api?.[action]) {
return config.api[action];
}
// 방향 B: entityType으로 범용 API 생성
if (config.entityType) {
const base = `/api/v1/entities/${config.entityType}`;
if (action === 'list' || action === 'create') return base;
return `${base}/:id`;
}
throw new Error(`No API config for action: ${action}`);
}
```
#### 11-4. API 응답 구조 통일
어느 방향이든 **응답 구조는 통일**되어야 프론트가 범용 처리 가능:
| API 유형 | 응답 구조 |
|---------|----------|
| list | `{ data: [...], meta: { total, current_page, per_page, last_page } }` |
| detail | `{ data: { ... } }` |
| create | `{ data: { id, ... }, message: "..." }` |
| update | `{ data: { id, ... }, message: "..." }` |
| delete | `{ message: "..." }` |
> ⚠️ **백엔드 논의 필요**:
> - 범용 entity API 도입 여부 및 범위
> - 기존 API 중 응답 구조가 통일되지 않은 것 정리
> - 전용 API와 범용 API의 분류 기준 합의
---
### 규칙 12: 검증(Validation) 규칙
| 검증 타입 | JSON 표현 | 프론트 변환 |
|----------|----------|------------|
| 필수값 | `{ "required": true }` | `z.string().min(1)` |
| 최솟값 | `{ "min": 1 }` | `z.number().min(1)` |
| 최댓값 | `{ "max": 100 }` | `z.number().max(100)` |
| 정규식 | `{ "pattern": "^\\d{3}-\\d{2}$" }` | `z.string().regex()` |
| 커스텀 메시지 | `{ "message": "올바른 형식이 아닙니다" }` | 에러 메시지 |
| 이메일 | `{ "type": "email" }` | `z.string().email()` |
| 전화번호 | `{ "type": "phone" }` | `z.string().regex()` |
```
JSON validation config
↓ 런타임 변환
Zod 스키마 자동 생성
react-hook-form zodResolver에 주입
폼 검증 자동 적용
```
---
### 규칙 13: 필드 간 의존성
| 의존성 타입 | 설명 | 예시 |
|------------|------|------|
| `visibility` | 조건부 표시/숨김 | 품목타입=모터 → 전압 필드 표시 |
| `computed` | 자동 계산 | 수량 × 단가 = 금액 |
| `cascade` | 연쇄 선택 | 대분류 → 중분류 → 소분류 |
| `setValue` | 값 자동 설정 | 거래처 선택 → 담당자 자동 입력 |
| `disable` | 조건부 비활성화 | 상태=확정 → 수량 수정 불가 |
```
기존 자산 활용:
DynamicItemForm의 DisplayCondition → visibility 타입으로 범용화
DynamicItemForm의 ComputedField → computed 타입으로 범용화
```
> ✅ **확정**: 복잡한 계산식(견적 할인율 등)은 **백엔드에서 전부 처리**하여 결과만 전달
---
### 규칙 14: 권한 통합
#### 14-1. 현재 권한 시스템 검증 결과
**현재 권한 시스템으로 동적 페이지도 컨트롤 가능** (검증 완료)
현재 권한 시스템이 **메뉴 ID 기반 + URL 패턴 매칭**으로 동작하므로, 페이지가 정적이든 동적이든 해당 URL이 menu 테이블에 등록되어 있으면 권한 관리 페이지에서 동일하게 컨트롤됩니다.
```
현재 (정적 페이지):
백엔드 menu 테이블에 URL 등록 → 권한 매트릭스 체크박스 on/off
→ PermissionGate가 URL 매칭 → 접근 허용/차단
동적 페이지도 동일:
백엔드 menu 테이블에 동적 페이지 URL(slug) 등록
→ 권한 매트릭스에서 동일하게 체크박스 on/off
→ PermissionGate가 URL 매칭 → 동일하게 동작
```
#### 14-2. 권한 레벨별 동적 페이지 호환성
| 권한 레벨 | 현재 지원 | 동적 페이지 호환 | 사용 컴포넌트 |
|----------|:---:|:---:|------|
| 페이지 접근 (view) | ✅ | ✅ | `PermissionGate` (URL 매칭) |
| 생성 (create) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 수정 (update) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 삭제 (delete) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 승인 (approve) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 내보내기 (export) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| 관리 (manage) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
| **필드 단위 권한** | ❌ | ❌ | 현재 미지원 → **v2 고려사항** |
#### 14-3. 권한 적용 흐름
```
권한 적용 흐름 (정적/동적 공통):
1. 페이지 접근: PermissionGate → URL longest prefix 매칭 → view 권한 확인
2. 액션 권한: usePermission() → canCreate/canDelete 등 → 버튼 표시/숨김
3. 필드 권한: 현재 미지원 (v2에서 config.permissions.fieldLevel 추가 시 구현)
```
> ⚠️ **백엔드 논의 필요**: 동적 페이지 URL(slug)을 menu 테이블에 자동 등록하는 방안
> (기준관리에서 페이지 생성 시 → menu 테이블에도 자동 연동?)
---
### 규칙 15: 캐싱 & 성능 전략
```
요청 흐름:
1차 캐시 (Zustand 메모리)
↓ miss
2차 캐시 (localStorage, 테넌트별 격리)
↓ miss
3차 (API 호출)
↓ 응답
1차 + 2차 캐시 갱신
```
| 전략 | 방법 | 갱신 주기 |
|------|------|----------|
| 초기 로드 | 로그인 시 전체 config 프리페치 | 1회 |
| 변경 감지 | 해시 비교 (메뉴 갱신과 동일) | 30초~5분 |
| 강제 갱신 | 관리자가 기준관리 변경 시 push | 즉시 |
| 캐시 무효화 | 테넌트 전환 시 전체 클리어 | 즉시 |
---
### 규칙 16: 비즈니스 로직 처리
> ✅ **확정**: 복잡한 계산 수식은 **백엔드에서 전부 처리**하여 결과만 전달
| 로직 복잡도 | 처리 방식 | 예시 |
|------------|----------|------|
| 단순 계산 | config formula (프론트) | 수량 × 단가 = 금액 |
| 복잡한 계산 | **백엔드 API** | 견적 할인, 세금, 재고 검증 등 |
```jsonc
// config에서 로직 지정
{
"businessLogic": {
// 단순: 프론트 formula (기존 ComputedField 재사용)
"amount": { "type": "formula", "expression": "quantity * unitPrice" },
// 복잡: 백엔드 위임 (확정)
"totalDiscount": {
"type": "api",
"endpoint": "/api/v1/quotes/:id/calculate-discount",
"trigger": "onFieldChange",
"watchFields": ["quantity", "unitPrice", "discountRate"]
}
}
}
```
프론트는 단순 사칙연산(ComputedField)만 담당하고, 그 외 모든 비즈니스 로직은 백엔드 API로 위임합니다.
---
### 규칙 17: 점진적 마이그레이션 전략
| Phase | 범위 | 예상 기간 | 상태 |
|-------|------|----------|------|
| **Phase 0** | 인프라 구축 | 2-3주 | ⏳ 준비 |
| | - catch-all 라우터 | | |
| | - pageConfigStore | | |
| | - DynamicListPage/FormPage 렌더러 | | |
| | - 백엔드 page-config API | | |
| **Phase 1** | 신규 테넌트/페이지만 동적 | 2-4주 | ⏳ |
| | - 새로 추가되는 페이지는 동적으로 생성 | | |
| | - 기존 페이지는 그대로 유지 | | |
| **Phase 2** | 단순 CRUD 페이지 전환 | 4-6주 | ⏳ |
| | - 리스트+상세만 있는 단순 페이지 | | |
| | - 거래처관리, 설비관리 등 | | |
| **Phase 3** | 복잡한 비즈니스 페이지 전환 | 6-8주 | ⏳ |
| | - 견적, 수주, 생산 등 로직 있는 페이지 | | |
| | - 로직 블록 구축 병행 | | |
| **Phase 4** | 기존 정적 → 동적 완전 전환 | 지속적 | ⏳ |
| | - 남은 하드코딩 페이지 점진적 전환 | | |
```
전환 판단 기준:
[쉬움] 순수 CRUD (리스트+폼) → Phase 2에서 전환
[보통] CRUD + 단순 계산 → Phase 2~3
[어려움] 복잡한 비즈니스 로직 → Phase 3
[마지막] 문서/프린트, 대시보드 → Phase 4
```
---
## 4. 이미 있는 자산 → 재사용 매핑
| 기존 자산 | 현재 용도 | 동적 시스템에서의 역할 |
|----------|----------|---------------------|
| DynamicFieldRenderer (14종) | 품목 폼 필드 | → 모든 동적 폼 필드 |
| DynamicTableSection | 품목 BOM 테이블 | → 모든 동적 테이블 |
| DisplayCondition | 품목 조건부 표시 | → 범용 visibility 규칙 |
| ComputedField | 품목 자동 계산 | → 범용 computed 규칙 |
| UniversalListPage | 리스트 페이지 템플릿 | → DynamicListPage 기반 |
| IntegratedDetailTemplate | 상세 페이지 템플릿 | → DynamicDetailPage 기반 |
| TenantAwareCache | 캐시 격리 | → pageConfigStore 캐시 |
| menuRefresh (해시 비교) | 메뉴 갱신 | → config 변경 감지 |
| buildApiUrl | URL 빌더 | → 동적 API 호출에 재사용 |
---
## 5. 논의 현황 정리
### 확정 사항
| 항목 | 확정 내용 | 비고 |
|------|----------|------|
| API 제공 방식 | 하이브리드 (C) — 단순 CRUD는 범용, 복잡 로직은 전용 | 범용 API 세분화 가능성 있음 |
| 복잡한 계산 수식 | 백엔드에서 전부 처리, 결과만 전달 | 프론트는 단순 사칙연산만 |
| 권한 관리 호환성 | 현재 권한 시스템으로 동적 페이지 컨트롤 가능 | 메뉴 ID + URL 패턴 매칭 방식 |
| 기존 동적 필드 재사용 | DynamicFieldRenderer 14종 등 90%+ 재사용 가능 | 기준관리 UI가 mng로 이동해도 렌더링 컴포넌트 유지 |
| DB 저장 방식 | PostgreSQL **JSONB** 사용 | 인덱싱/부분수정/내부검색 가능, 프론트 영향 없음 |
### 협의 필요 사항
| 항목 | 현재 상태 | 논의 포인트 |
|------|----------|------------|
| JSON config 세부 구조 | 제안 구조 작성됨 (규칙 2 참조) | 회의에서 세부 항목 결정 후 확정 |
| 정적/동적 페이지 분류 | 초안 목록 작성됨 (규칙 3 참조) | 어떤 페이지를 정적으로 남길지 최종 확정 |
| 테넌트 하위 분기 정책 | 개념 정리됨 (규칙 7 참조) | 테넌트→부서→역할 오버라이드 정책, config를 최종 결과물로 줄지 프론트가 조합할지 |
| 동적 라우팅 전략 | catch-all 방식 제안 (규칙 10 참조) | 기존 정적 페이지와의 공존/전환 전략 |
| 범용 entity API 범위 | 하이브리드 방향 합의 | 페이지 렌더링 분기에 따라 범용 API 세분화 가능 |
| page-config API 스펙 | 미정 | `GET /api/v1/page-configs/{slug}` 응답 구조 |
| 기준관리 어드민 UI | 미정 | mng에서 레이아웃/섹션/필드 등록 화면 설계 |
| API 응답 통일 | 미정 | list/detail/create/update/delete 응답 포맷 표준화 |
| 캐시 무효화 | 미정 | 기준관리 변경 시 프론트 캐시 갱신 방법 (polling vs push) |
| 프리페치 범위 | 미정 | 로그인 시 전체 config vs 페이지 접근 시 개별 로드 |
| 검증/의존성 JSON 스펙 | 제안 구조 작성됨 (규칙 12, 13 참조) | 세부 스펙 확정 |
| 마이그레이션 순서 | Phase 0~4 제안 (규칙 17 참조) | 어떤 페이지부터 동적 전환할지 |
| 동적 페이지 → menu 자동 등록 | 미정 | 기준관리에서 페이지 생성 시 menu 테이블 자동 연동 방안 |
| 필드 단위 권한 | 현재 미지원 | v2 고려사항 (필요 시 추가 개발) |
---
## 6. 기존 자산 재사용 현황
### 즉시 재사용 가능 (코드 변경 없음)
| 자산 | 현재 용도 | 동적 시스템 역할 | 재사용도 |
|------|----------|----------------|:---:|
| DynamicFieldRenderer (14종) | 품목 폼 필드 | 모든 동적 폼 필드 | 100% |
| DynamicTableSection | 품목 BOM 테이블 | 모든 동적 테이블 | 99% |
| DisplayCondition (9개 연산자) | 품목 조건부 표시 | 범용 visibility 규칙 | 100% |
| ComputedField | 품목 자동 계산 | 범용 단순 계산 | 100% |
| Reference Sources 프리셋 | 거래처/품목 등 조회 | 새 source 추가만으로 확장 | 100% |
| TenantAwareCache | 캐시 격리 | pageConfigStore 캐시 | 100% |
| menuRefresh (해시 비교) | 메뉴 갱신 | config 변경 감지 | 100% |
| buildApiUrl | URL 빌더 | 동적 API 호출 | 100% |
| PermissionGate / usePermission | 정적 페이지 권한 | 동적 페이지 권한 (동일) | 100% |
### 범용화 필요 (약간의 리팩토링)
| 자산 | 변경 사항 |
|------|----------|
| useDynamicFormState | API URL을 파라미터로 받도록 |
| useFormStructure | 품목 전용 API → 범용 API 경로 |
| types.ts | `ItemFieldResponse``DynamicFieldResponse` 리네이밍 |
### 신규 개발 필요
| 자산 | 역할 |
|------|------|
| DynamicListPage | 동적 리스트 페이지 렌더러 (UniversalListPage 기반) |
| DynamicDetailPage | 동적 상세 페이지 렌더러 (IntegratedDetailTemplate 기반) |
| DynamicDashboardPage | 동적 대시보드 렌더러 |
| pageConfigStore | 페이지 config Zustand 스토어 |
| catch-all route | `[...slug]/page.tsx` 동적 라우터 |
| resolveApiUrl | API 경로 결정 유틸 (개별/범용 분기) |
---
## 7. 관련 문서
| 문서 | 위치 | 내용 |
|------|------|------|
| 동적 렌더링 플랫폼 비전 | `claudedocs/architecture/[VISION-2026-02-19]` | 전체 비전 및 자산 현황 |
| 멀티테넌시 최적화 로드맵 | `claudedocs/architecture/[PLAN-2026-02-06]` | 테넌트 격리/최적화 8 Phase |
| 동적 필드 타입 설계 | `claudedocs/architecture/[DESIGN-2026-02-11]` | 4-Level 구조, 14종 필드 |
| 동적 필드 구현 현황 | `claudedocs/architecture/[IMPL-2026-02-11]` | Phase 1~3 프론트 구현 완료 |
| 백엔드 API 스펙 | `claudedocs/item-master/[API-REQUEST-2026-02-12]` | 동적 필드 타입 백엔드 요청서 |
---
**문서 버전**: 1.2
**마지막 업데이트**: 2026-03-11
**다음 단계**: 백엔드 회의 → 협의 필요 항목 확정 → v2.0 작성 → `sam-docs/frontend/v2/`에 최종본 등록

View File

@@ -0,0 +1,166 @@
# [TODO] 유저 개별 설정 DB 이관 계획
> 현재 localStorage에 저장 중인 유저별 설정을 백엔드 DB로 이관하여 크로스 디바이스 동기화 지원
---
## 현재 현황: localStorage 기반 유저 설정 목록
### 🔴 HIGH — 우선 이관 대상
| 항목 | 저장 키 | 파일 | 유저 분리 | 설명 |
|------|---------|------|-----------|------|
| 즐겨찾기 | `sam-favorites-{userId}` | `stores/favoritesStore.ts` | ✅ | 메뉴 즐겨찾기 (최대 10개) |
| 테이블 컬럼 설정 | `sam-table-columns-{userId}` | `stores/useTableColumnStore.ts` | ✅ | 컬럼 너비, 숨김 여부 (페이지별) |
### 🟡 MEDIUM — 2차 이관 대상
| 항목 | 저장 키 | 파일 | 유저 분리 | 설명 |
|------|---------|------|-----------|------|
| 테마 | `theme` | `stores/themeStore.ts` | ❌ 공용 | light / dark / senior |
| 글꼴 크기 | `sam-font-size` | `layouts/AuthenticatedLayout.tsx` | ❌ 공용 | 12~20px (기본 16) |
| 사이드바 접힘 | `sam-menu` | `stores/menuStore.ts` | ❌ 공용 | sidebarCollapsed 상태 |
| 알림 설정 | `ITEM_VISIBILITY_STORAGE_KEY` | `settings/NotificationSettings/index.tsx` | ❌ 공용 | 알림 카테고리별 표시 여부 |
### 🟢 LOW — 선택적 이관
| 항목 | 저장 키 | 파일 | 설명 |
|------|---------|------|------|
| 팝업 오늘 하루 안 보기 | `popup_dismissed_{id}` | `common/NoticePopupModal.tsx` | 매일 자동 리셋, 임시성 |
### ❌ 제외 (이관 불필요)
| 항목 | 이유 |
|------|------|
| Auth 토큰 (HttpOnly 쿠키) | 이미 서버 관리 |
| Auth Store (mes-users, mes-currentUser) | 인증 플로우 전용 |
| Master Data 캐시 (sessionStorage) | TTL 기반 캐시, 설정 아님 |
| Dashboard Stale 캐시 (sessionStorage) | 세션 캐시 |
| Page Builder (page-builder-pages) | 개발 전용 도구 |
---
## 백엔드 DB 스키마 (안)
### user_preferences (통합 설정 테이블)
```sql
CREATE TABLE user_preferences (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
theme VARCHAR(20) DEFAULT 'light',
font_size TINYINT UNSIGNED DEFAULT 16,
sidebar_collapsed BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY (tenant_id, user_id)
);
```
### user_favorites (즐겨찾기)
```sql
CREATE TABLE user_favorites (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
menu_id VARCHAR(100) NOT NULL,
label VARCHAR(255) NOT NULL,
icon_name VARCHAR(100),
path VARCHAR(500) NOT NULL,
display_order TINYINT UNSIGNED DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY (tenant_id, user_id, menu_id)
);
```
### user_table_preferences (테이블 컬럼 설정)
```sql
CREATE TABLE user_table_preferences (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
page_id VARCHAR(100) NOT NULL,
settings JSON NOT NULL, -- { columnWidths: {...}, hiddenColumns: [...] }
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY (tenant_id, user_id, page_id)
);
```
### user_notification_preferences (알림 설정)
```sql
CREATE TABLE user_notification_preferences (
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
tenant_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
settings JSON NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY (tenant_id, user_id)
);
```
---
## API 엔드포인트 (안)
### Phase 1 (즐겨찾기 + 테이블 설정)
```
GET /api/v1/user/preferences — 전체 설정 조회
PATCH /api/v1/user/preferences — 설정 부분 업데이트
GET /api/v1/user/favorites — 즐겨찾기 목록
POST /api/v1/user/favorites — 즐겨찾기 추가
DELETE /api/v1/user/favorites/{menuId} — 즐겨찾기 삭제
PATCH /api/v1/user/favorites/reorder — 순서 변경
GET /api/v1/user/table-preferences/{pageId} — 페이지별 컬럼 설정
PUT /api/v1/user/table-preferences/{pageId} — 컬럼 설정 저장
```
### Phase 2 (테마/글꼴/사이드바/알림)
```
GET /api/v1/user/preferences — 위와 동일 (theme, font_size 포함)
PATCH /api/v1/user/preferences — 위와 동일
GET /api/v1/user/notification-preferences
PUT /api/v1/user/notification-preferences
```
---
## 이관 전략
### 단계별 마이그레이션
1. **DB 테이블 + API 생성** (백엔드)
2. **Dual-write 패턴 적용** (프론트)
- 저장 시: API 호출 + localStorage 동시 기록
- 읽기 시: API 우선 → localStorage 폴백
3. **안정화 후 localStorage 제거**
### 프론트 전환 패턴 (예시)
```typescript
// createUserStorage → createUserStorageAPI 전환
export function createUserStorageAPI(baseKey: string) {
return {
getItem: async () => {
const res = await fetch(`/api/v1/user/${baseKey}`);
return res.ok ? res.json() : null;
},
setItem: async (value: unknown) => {
await fetch(`/api/v1/user/${baseKey}`, {
method: 'PUT',
body: JSON.stringify(value),
});
},
};
}
```
---
## 우선순위 정리
| 단계 | 대상 | 이유 |
|------|------|------|
| Phase 1 | 즐겨찾기, 테이블 컬럼 | 유저별 분리 이미 되어있어 구조 전환 쉬움, 사용 빈도 높음 |
| Phase 2 | 테마, 글꼴, 사이드바 | 현재 유저 분리 안 됨 → DB 이관하면서 유저별 적용 |
| Phase 3 | 알림 설정 | 기능 안정화 후 진행 |

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -136,15 +136,37 @@ http://localhost:3000/ko/material/stock-status # 🆕 재고현황
|--------|-----|------| |--------|-----|------|
| **검사관리** | `/ko/quality/inspections` | 🆕 NEW | | **검사관리** | `/ko/quality/inspections` | 🆕 NEW |
| **실적신고관리** | `/ko/quality/performance-reports` | 🆕 NEW | | **실적신고관리** | `/ko/quality/performance-reports` | 🆕 NEW |
| **설비 등록대장** | `/ko/quality/equipment` | 🆕 NEW |
| **설비 현황** | `/ko/quality/equipment-status` | 🆕 NEW |
| **일상점검표** | `/ko/quality/equipment-inspections` | 🆕 NEW |
| **수리이력** | `/ko/quality/equipment-repairs` | 🆕 NEW |
``` ```
http://localhost:3000/ko/quality/inspections # 🆕 검사관리 http://localhost:3000/ko/quality/inspections # 🆕 검사관리
http://localhost:3000/ko/quality/performance-reports # 🆕 실적신고관리 http://localhost:3000/ko/quality/performance-reports # 🆕 실적신고관리
http://localhost:3000/ko/quality/equipment # 🆕 설비 등록대장
http://localhost:3000/ko/quality/equipment-status # 🆕 설비 현황
http://localhost:3000/ko/quality/equipment-inspections # 🆕 일상점검표
http://localhost:3000/ko/quality/equipment-repairs # 🆕 수리이력
``` ```
--- ---
## 🚗 차량/지게차 (Vehicle Management) ## 🚗 차량관리 (Vehicle)
| 페이지 | URL | 상태 |
|--------|-----|------|
| **법인차량관리** | `/ko/vehicle/corporate-vehicles` | 🆕 NEW |
| **차량일지** | `/ko/vehicle/vehicle-logs` | 🆕 NEW |
| **정비이력** | `/ko/vehicle/vehicle-maintenance` | 🆕 NEW |
```
http://localhost:3000/ko/vehicle/corporate-vehicles # 🆕 법인차량관리
http://localhost:3000/ko/vehicle/vehicle-logs # 🆕 차량일지
http://localhost:3000/ko/vehicle/vehicle-maintenance # 🆕 정비이력
```
### 이전 차량/지게차 (레거시)
| 페이지 | URL | 상태 | | 페이지 | URL | 상태 |
|--------|-----|------| |--------|-----|------|
@@ -401,7 +423,14 @@ http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
http://localhost:3000/ko/outbound/vehicle-dispatches # 🆕 배차차량관리 http://localhost:3000/ko/outbound/vehicle-dispatches # 🆕 배차차량관리
``` ```
### Vehicle Management (차량/지게차) ### Vehicle (차량관리)
```
http://localhost:3000/ko/vehicle/corporate-vehicles # 🆕 법인차량관리
http://localhost:3000/ko/vehicle/vehicle-logs # 🆕 차량일지
http://localhost:3000/ko/vehicle/vehicle-maintenance # 🆕 정비이력
```
### Vehicle Management (레거시 차량/지게차)
``` ```
http://localhost:3000/ko/vehicle-management/vehicle # 🆕 차량관리 http://localhost:3000/ko/vehicle-management/vehicle # 🆕 차량관리
http://localhost:3000/ko/vehicle-management/vehicle-log # 🆕 차량일지/월간사진기록 http://localhost:3000/ko/vehicle-management/vehicle-log # 🆕 차량일지/월간사진기록
@@ -519,12 +548,21 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
// Quality (품질관리) // Quality (품질관리)
'/quality/inspections' // 검사관리 (🆕 NEW) '/quality/inspections' // 검사관리 (🆕 NEW)
'/quality/performance-reports' // 실적신고관리 (🆕 NEW) '/quality/performance-reports' // 실적신고관리 (🆕 NEW)
'/quality/equipment' // 설비 등록대장 (🆕 NEW)
'/quality/equipment-status' // 설비 현황 (🆕 NEW)
'/quality/equipment-inspections' // 일상점검표 (🆕 NEW)
'/quality/equipment-repairs' // 수리이력 (🆕 NEW)
// Outbound (출고관리) // Outbound (출고관리)
'/outbound/shipments' // 출하관리 (🆕 NEW) '/outbound/shipments' // 출하관리 (🆕 NEW)
'/outbound/vehicle-dispatches' // 배차차량관리 (🆕 NEW) '/outbound/vehicle-dispatches' // 배차차량관리 (🆕 NEW)
// Vehicle Management (차량/지게차) // Vehicle (차량관리)
'/vehicle/corporate-vehicles' // 법인차량관리 (🆕 NEW)
'/vehicle/vehicle-logs' // 차량일지 (🆕 NEW)
'/vehicle/vehicle-maintenance' // 정비이력 (🆕 NEW)
// Vehicle Management (레거시 차량/지게차)
'/vehicle-management/vehicle' // 차량관리 (🆕 NEW) '/vehicle-management/vehicle' // 차량관리 (🆕 NEW)
'/vehicle-management/vehicle-log' // 차량일지/월간사진기록 (🆕 NEW) '/vehicle-management/vehicle-log' // 차량일지/월간사진기록 (🆕 NEW)
'/vehicle-management/forklift' // 지게차 관리 (🆕 NEW) '/vehicle-management/forklift' // 지게차 관리 (🆕 NEW)

View File

@@ -9,9 +9,10 @@
1. [공통 컴포넌트 맵](#1-공통-컴포넌트-맵) 1. [공통 컴포넌트 맵](#1-공통-컴포넌트-맵)
2. [검색 모달 (SearchableSelectionModal)](#2-검색-모달) 2. [검색 모달 (SearchableSelectionModal)](#2-검색-모달)
3. [리스트 페이지](#3-리스트-페이지) 3. [리스트 페이지](#3-리스트-페이지)
4. [상세/폼 페이지](#4-상세폼-페이지) 4. [IntegratedListTemplateV2 표준 적용](#4-integratedlisttemplatev2-표준-적용)
5. [API 연동 패턴](#5-api-연동-패턴) 5. [상세/폼 페이지](#5-상세폼-페이지)
6. [페이지 라우팅 구조](#6-페이지-라우팅-구조) 6. [API 연동 패턴](#6-api-연동-패턴)
7. [페이지 라우팅 구조](#7-페이지-라우팅-구조)
--- ---
@@ -304,7 +305,367 @@ export function MyList() {
--- ---
## 4. 상세/폼 페이지 ## 4. IntegratedListTemplateV2 표준 적용
### 개요
`IntegratedListTemplateV2`는 프로젝트의 표준 리스트 페이지 템플릿으로, 아래 기능을 한 번에 제공한다:
- PageLayout + PageHeader (아이콘/제목/설명)
- 날짜/검색/버튼 헤더 영역
- 통계 카드
- 테이블 (체크박스/번호/데이터/작업) + 페이지네이션
- 모바일 카드 뷰 자동 전환
- 컬럼 설정 (표시/숨기기/리사이즈)
**위치**: `src/components/templates/IntegratedListTemplateV2.tsx`
### 🔴 적용 시 필수 체크리스트
IntegratedListTemplateV2를 사용하는 페이지를 만들거나 리팩토링할 때, **아래 항목을 반드시 확인**한다.
| # | 항목 | 설명 | 필수 |
|---|------|------|:----:|
| 1 | **컬럼 설정** | `useColumnSettings` + `ColumnSettingsPopover` + `columnSettings` prop | ✅ |
| 2 | **검색** | `searchValue` + `onSearchChange` + `searchPlaceholder` | ✅ |
| 3 | **체크박스 선택** | `selectedItems (Set<string>)` + `onToggleSelection` + `onToggleSelectAll` + `getItemId` | ✅ |
| 4 | **페이지네이션** | `pagination` (currentPage, totalPages, totalItems, itemsPerPage, onPageChange) | ✅ |
| 5 | **모바일 카드** | `renderMobileCard` + `MobileCard` / `InfoField` 사용 | ✅ |
| 6 | **테이블 행** | `renderTableRow` (TableRow + TableCell 조합) | ✅ |
| 7 | **헤더 레이아웃** | 순서: `[검색] [날짜/연월] --- [액션버튼] [등록버튼]` | ✅ |
| 8 | **통계 카드** | `stats` 배열 (label, value, icon, iconColor) | 권장 |
| 9 | **테이블 내 필터** | `filterConfig` 통합 필터 사용 (PC: 인라인, 모바일: 바텀시트 자동 분기). `tableHeaderActions`에 Select 직접 넣기 금지 | ✅ |
| 10 | **탭** | `tabsContent` (커스텀) 또는 `tabs` + `activeTab` + `onTabChange` | 필요 시 |
### 컬럼 설정 (필수 패턴)
**매번 빠뜨리지 않도록 3가지 세트로 기억한다:**
```typescript
// 1⃣ Hook 선언
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'name', label: '이름', copyable: true },
// ...
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];
const {
visibleColumns, // → tableColumns prop에 전달
allColumnsWithVisibility, // → ColumnSettingsPopover에 전달
columnWidths, // → columnSettings.columnWidths
setColumnWidth, // → columnSettings.onColumnResize
toggleColumnVisibility, // → ColumnSettingsPopover.onToggle
resetSettings, // → ColumnSettingsPopover.onReset
hasHiddenColumns, // → ColumnSettingsPopover.hasHiddenColumns
} = useColumnSettings({
pageId: 'my-page-id', // Zustand 저장 키 (고유값)
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'name', 'actions'], // 숨기기 불가 컬럼
});
```
```tsx
// 2⃣ 템플릿에 전달
<IntegratedListTemplateV2
tableColumns={visibleColumns} // ← TABLE_COLUMNS 아닌 visibleColumns!
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
// ...
/>
```
### 헤더 레이아웃 순서
표준 레이아웃은 아래 순서를 따른다:
```
[아이콘] 페이지 제목
설명 텍스트
[검색창] [날짜/연월 셀렉트] --- [액션버튼들] [+ 등록 버튼]
[탭: 목록 | 설정] (tabsContent, 필요 시)
[통계카드 ...] (stats)
[전체 N건 | N개 선택됨] [부서 필터] [상태 필터] [컬럼 설정] (tableHeaderActions)
[테이블]
[페이지네이션]
```
**날짜 대신 연월 셀렉트가 필요한 경우:**
```typescript
dateRangeSelector={{
enabled: true,
hideDateInputs: true, // 날짜 입력 숨김
showPresets: false, // 프리셋 버튼 숨김
extraActions: ( // 대신 연월 셀렉트 배치
<div className="flex items-center gap-2">
<Select value={String(year)} onValueChange={...}>...</Select>
<Select value={String(month)} onValueChange={...}>...</Select>
</div>
),
}}
```
### 🔴 테이블 내 필터 — filterConfig 통합 방식 (필수)
테이블 카드 내부 필터는 **반드시 `filterConfig` 통합 필터 시스템**을 사용한다.
- PC(xl 이상): 인라인 Select로 자동 렌더링
- 모바일/태블릿(xl 미만): 바텀시트(`MobileFilter`)로 자동 분기
**❌ 금지 패턴**: `tableHeaderActions`에 직접 Select를 넣으면 **모바일에서 필터가 보이지 않는다**.
```tsx
import {
IntegratedListTemplateV2,
type TableColumn,
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/IntegratedListTemplateV2';
// 1⃣ filterConfig 정의
const filterConfig: FilterFieldConfig[] = useMemo(() => [
{
key: 'department',
label: '부서',
type: 'single',
options: departments.map(d => ({ value: d, label: d })),
allOptionLabel: '전체 부서',
},
{
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'draft', label: '작성중' },
{ value: 'confirmed', label: '확정' },
],
allOptionLabel: '전체 상태',
},
], [departments]);
// 2⃣ filterValues 상태 연결
const filterValues: FilterValues = useMemo(() => ({
department: filterDepartment,
status: filterStatus,
}), [filterDepartment, filterStatus]);
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
if (key === 'department') { setFilterDepartment(value as string); setCurrentPage(1); }
if (key === 'status') { setFilterStatus(value as string); setCurrentPage(1); }
}, []);
const handleFilterReset = useCallback(() => {
setFilterDepartment('all');
setFilterStatus('all');
setCurrentPage(1);
}, []);
// 3⃣ tableHeaderActions에는 필터 외 액션만 (엑셀 등)
const tableHeaderActions = useMemo(() => (
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
<Download className="mr-1 h-4 w-4" />
엑셀
</Button>
), [handleExcelDownload]);
// 4⃣ 템플릿에 전달
<IntegratedListTemplateV2
filterConfig={filterConfig}
filterValues={filterValues}
onFilterChange={handleFilterChange}
onFilterReset={handleFilterReset}
filterTitle="검색 필터"
tableHeaderActions={tableHeaderActions} // 엑셀 등 비필터 액션만
// ...
/>
```
| prop | 역할 | 필수 |
|------|------|:----:|
| `filterConfig` | 필터 필드 정의 (key, label, type, options) | ✅ |
| `filterValues` | 현재 필터 상태 | ✅ |
| `onFilterChange` | 필터 값 변경 핸들러 | ✅ |
| `onFilterReset` | 필터 초기화 핸들러 | ✅ |
| `filterTitle` | 모바일 바텀시트 타이틀 (기본: "검색 필터") | 권장 |
| `tableHeaderActions` | 필터 외 액션 (엑셀 버튼 등) | 필요 시 |
### 모바일 카드 (renderMobileCard)
```tsx
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
const renderMobileCard = useCallback((
item: MyItem,
_index: number,
_globalIndex: number,
isSelected: boolean,
onToggle: () => void,
) => (
<MobileCard
key={item.id}
title={item.name}
subtitle={item.department || '-'}
headerBadges={[
{ text: STATUS_LABELS[item.status], variant: STATUS_VARIANTS[item.status] },
]}
infoGrid={[
<InfoField key="amount" label="금액" value={formatCurrency(item.amount)} />,
<InfoField key="date" label="날짜" value={item.date} />,
]}
isSelected={isSelected}
onToggleSelection={onToggle}
onClick={() => handleDetailOpen(item.id)}
/>
), []);
```
### 체크박스 선택 (Set\<string\>)
IntegratedListTemplateV2는 **문자열 ID** (`Set<string>`)를 요구한다:
```typescript
// ✅ 올바른 패턴
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const toggleSelection = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}, []);
const toggleSelectAll = useCallback(() => {
setSelectedIds(prev =>
prev.size === data.length
? new Set()
: new Set(data.map(item => String(item.id)))
);
}, [data]);
// 사용
<IntegratedListTemplateV2
selectedItems={selectedIds}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => String(item.id)}
/>
```
### 전체 스켈레톤 예제
```tsx
'use client';
import { IntegratedListTemplateV2, type TableColumn } from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
import { TableRow, TableCell } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { MyIcon } from 'lucide-react';
const TABLE_COLUMNS: TableColumn[] = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'name', label: '이름', copyable: true },
{ key: 'status', label: '상태', className: 'text-center w-[80px]' },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];
export function MyListPage() {
// 컬럼 설정 (필수)
const {
visibleColumns, allColumnsWithVisibility, columnWidths,
setColumnWidth, toggleColumnVisibility, resetSettings, hasHiddenColumns,
} = useColumnSettings({
pageId: 'my-page',
columns: TABLE_COLUMNS,
alwaysVisibleKeys: ['no', 'name', 'actions'],
});
// 선택 (Set<string>)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// ... toggleSelection, toggleSelectAll 구현
return (
<IntegratedListTemplateV2<MyItem>
// 헤더
title="페이지 제목"
description="설명"
icon={MyIcon}
// 헤더 액션
headerActions={<Button>액션</Button>}
createButton={{ label: '등록', onClick: handleCreate }}
// 검색
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="검색..."
// 통계
stats={[{ label: '전체', value: totalCount, icon: Users, iconColor: 'text-blue-600' }]}
// 테이블 필터
tableHeaderActions={filterNode}
// 테이블 + 컬럼 설정
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
// 데이터
data={items}
selectedItems={selectedIds}
onToggleSelection={toggleSelection}
onToggleSelectAll={toggleSelectAll}
getItemId={(item) => String(item.id)}
// 렌더링
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
// 페이지네이션
pagination={{
currentPage, totalPages, totalItems: totalCount,
itemsPerPage: PAGE_SIZE, onPageChange: setCurrentPage,
}}
// 로딩
isLoading={isLoading}
/>
);
}
```
---
## 5. 상세/폼 페이지
### 표준 구조 ### 표준 구조
@@ -405,18 +766,37 @@ export function MyDetail({ id, mode }: DetailProps) {
### 상세/폼 페이지 공통 규칙 ### 상세/폼 페이지 공통 규칙
- **모드**: `view` | `edit` | `new` 3가지 - **모드**: `view` | `edit` | `new` 3가지
- **라우팅**: `?mode=new` / `?mode=edit` 쿼리파라미터 사용 (별도 `/new`, `/edit` 경로 금지)
- **page.tsx 분기**: 목록 page.tsx에서 `searchParams.get('mode')` 로 등록 폼 분기
- **Hook 규칙**: 모든 hook은 최상단, 조건부 return은 그 아래 - **Hook 규칙**: 모든 hook은 최상단, 조건부 return은 그 아래
- **레이아웃**: `Card > CardHeader + CardContent` 섹션 단위 - **레이아웃**: `Card > CardHeader + CardContent` 섹션 단위
- **필드 그리드**: `grid grid-cols-1 md:grid-cols-2 gap-4` - **필드 그리드**: `grid grid-cols-1 md:grid-cols-2 gap-4`
- **disabled**: view 모드에서 모든 입력 비활성화 - **disabled**: view 모드에서 모든 입력 비활성화
- **알림**: `toast.success()` / `toast.error()` (sonner) - **알림**: `toast.success()` / `toast.error()` (sonner)
- **네비게이션**: `router.back()` 또는 `router.push()`
- **로딩**: Skeleton 컴포넌트 사용 - **로딩**: Skeleton 컴포넌트 사용
- **Select 버그 대응**: `<Select key={...}>` 패턴 (CLAUDE.md 참조) - **Select 버그 대응**: `<Select key={...}>` 패턴 (CLAUDE.md 참조)
#### 헤더 배치 표준
| 위치 | 요소 |
|------|------|
| 상단 좌측 | 페이지 제목 (`<h1>`) |
| 상단 우측 | `← 목록으로` (Button variant="link") |
#### 하단 Sticky 액션 바
Card 내부가 아닌 **sticky bottom bar**로 버튼 배치. 취소 좌측, 주요 액션 우측.
| 모드 | 좌측 | 우측 |
|------|------|------|
| 등록 (new) | `X 취소` | `💾 저장` |
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
| 수정 (edit) | `X 취소` | `💾 저장` |
- 아이콘 포함: 취소(`X`), 저장(`Save`), 수정(`Pencil`)
- 상세(view) "취소"는 목록 이동, "수정"은 `?mode=edit` 전환
--- ---
## 5. API 연동 패턴 ## 6. API 연동 패턴
### Server Action 파일 구조 ### Server Action 파일 구조
@@ -478,7 +858,7 @@ const handleFetchData = useCallback(async (query: string) => {
--- ---
## 6. 페이지 라우팅 구조 ## 7. 페이지 라우팅 구조
``` ```
src/app/[locale]/(protected)/[domain]/ src/app/[locale]/(protected)/[domain]/

View File

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

View File

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

View File

@@ -0,0 +1,532 @@
# 절곡품 모듈 구현 계획서
> 버디(경동기업 ERP) 절곡품 메뉴 분석 기반 SAM ERP 프론트엔드 구현 계획
> 작성일: 2026-03-13 | 백엔드 API 작업 진행 중
---
## 1. 전체 개요
### 버디 절곡품 메뉴 구조 (6개 하위 페이지)
| # | 메뉴명 | 버디 URL | 데이터 건수 | 핵심 기능 |
|---|--------|---------|-----------|---------|
| 1 | 절곡 바라시 기초자료 | `/bending/list.php` | 265건 | 절곡 형상 마스터 + 그리기 도구 |
| 2 | 재고생산/작업일지/중간검사성적서 | `/lot/list.php` | 201건 | LOT 관리 + 중간검사 PDF |
| 3 | 가이드레일 | `/guiderail/list.php` | 20건 | 제품 설계 + 전개도 + 작업지시서 |
| 4 | 케이스 (셔터박스) | `/shutterbox/list.php` | 30건 | 셔터박스 설계 + 전개도 + 작업지시서 |
| 5 | 하단마감재 | `/bottombar/list.php` | 11건 | 마감재 설계 + 작업지시서 |
| 6 | 절곡 재고현황 | `/lot/list_stock.php` | 집계 | 재고 요약 + 그룹별 현황 |
### SAM 라우트 구조 (신규)
```
src/app/[locale]/(protected)/production/bending/
├── page.tsx ← 절곡 바라시 기초자료
├── lot/
│ └── page.tsx ← 재고생산/작업일지/중간검사성적서
├── guiderail/
│ └── page.tsx ← 가이드레일
├── shutterbox/
│ └── page.tsx ← 케이스(셔터박스)
├── bottombar/
│ └── page.tsx ← 하단마감재
└── stock/
└── page.tsx ← 절곡 재고현황
```
### 컴포넌트 구조
```
src/components/production/bending/
├── BendingMasterList.tsx ← 바라시 기초자료 목록
├── BendingMasterForm.tsx ← 바라시 기초자료 등록/수정 (모달)
├── BendingLotList.tsx ← LOT 목록
├── BendingLotForm.tsx ← LOT 등록/수정 (모달)
├── GuiderailList.tsx ← 가이드레일 목록
├── GuiderailForm.tsx ← 가이드레일 등록/수정 (모달)
├── ShutterboxList.tsx ← 셔터박스 목록
├── ShutterboxForm.tsx ← 셔터박스 등록/수정 (모달)
├── BottombarList.tsx ← 하단마감재 목록
├── BottombarForm.tsx ← 하단마감재 등록/수정 (모달)
├── BendingStockSummary.tsx ← 재고 요약 카드
├── BendingStockTable.tsx ← 재고 현황 테이블
├── WorkOrderViewer.tsx ← 작업지시서 보기 (공통)
├── BlueprintImageManager.tsx ← 결합형태 이미지 관리 (공통)
├── actions.ts ← Server Actions
└── types.ts ← 타입 정의
```
### 🔄 기존 재활용 컴포넌트 (신규 생성 불필요)
| 기존 컴포넌트 | 경로 | 재활용 용도 |
|--------------|------|-----------|
| **DrawingCanvas** | `src/components/items/DrawingCanvas.tsx` | 절곡 형상 그리기 도구 (그대로 사용) |
| **BendingDiagramSection** | `src/components/items/ItemForm/BendingDiagramSection.tsx` | 전개도 파일 업로드/그리기/치수 테이블 UI 참조 |
| **BendingPartForm** | `src/components/items/ItemForm/forms/parts/BendingPartForm.tsx` | 품목명/종류/재질/품목코드 로직 참조 |
| **bending/types.ts** | `src/components/production/WorkOrders/documents/bending/types.ts` | 절곡 작업일지 타입 (GuideRailTypeData, ShutterBoxData 등) |
**DrawingCanvas 기능 현황** (품목관리에서 이미 사용 중):
- Canvas 600×400 (반응형 스케일)
- 도구: 펜, 직선, 사각형, 원, 텍스트, 지우개
- 색상 팔레트 10색 + 선 두께 조절 (1~20px)
- Undo, 전체 지우기, 히스토리 관리
- 초기 이미지 로드 (기존 이미지 편집 가능)
- PNG data URL로 저장 → onSave 콜백
- Dialog 모달로 래핑됨 (open/onOpenChange props)
**BendingDiagramSection 기능 현황** (품목 등록 폼에서 사용 중):
- 입력방식 선택: 파일 업로드 / 드로잉 (DrawingCanvas 연동)
- FileDropzone으로 이미지/PDF 업로드 + 미리보기
- 기존 파일 표시/다운로드/삭제 (수정 모드)
- 전개도 상세 입력 테이블: 번호, 입력값, 연신율, 계산값, 음영, A각
- 폭 합계 자동 계산 → setValue로 폼 필드 연동
---
## 2. 페이지별 상세 분석 및 구현 계획
### 2-1. 절곡 바라시 기초자료 (Bending Master)
#### 목록 페이지
**필터 영역:**
| 필터명 | 타입 | 옵션 | 기본값 |
|--------|------|------|--------|
| 대분류 | 라디오 버튼 (토글) | 전체 / 스크린 / 철재 | 전체 |
| 인정/비인정 | 라디오 버튼 (토글) | 전체 / 인정 / 비인정 | 전체 |
| 중분류 (절곡물 분류) | Select | 가이드레일, 케이스, 하단마감재, 마구리, L-BAR, 보강평철, 케이스용 연기차단재, 가이드레일용 연기차단재 | (중분류) |
| 품명 | Select (동적) | 중분류 선택에 따라 변경 (~78개 옵션) | (품명) |
| 키워드 검색 | 텍스트 입력 | - | - |
**테이블 컬럼:**
| 순서 | 컬럼명 | 설명 | 정렬 |
|------|--------|------|------|
| 1 | NO | 순번 | ✅ |
| 2 | 등록일 | yyyy-MM-dd | ✅ |
| 3 | 대분류 | 스크린/철재 | ✅ |
| 4 | 인정/비인정 | 인정/비인정 | ✅ |
| 5 | 절곡물 분류 | 중분류 카테고리 | ✅ |
| 6 | 품명 | 링크 (상세 팝업) | ✅ |
| 7 | 규격(가로*세로) | 치수 | ✅ |
| 8 | 이미지(형상) | 썸네일 이미지 | - |
| 9 | 재질 | EGI 1.55T, SUS 1.2T 등 | ✅ |
| 10 | 폭 합계 | 숫자 | ✅ |
| 11 | 절곡회수 | 숫자 (색상 강조) | ✅ |
| 12 | 역방향(음영) | 숫자 | ✅ |
| 13 | A각 수 | 숫자 | ✅ |
| 14 | 폭합 | 숫자 | ✅ |
| 15 | 작성 | 작성자 | ✅ |
| 16 | 검색어 | 품목 검색 키워드 | ✅ |
| 17 | 비고 | 메모 | ✅ |
**액션 버튼:**
- `신규` → 등록 폼 팝업
- `절곡 모델설정 이동` → 별도 설정 페이지
- `절곡 BOM 이동` → BOM 관리 페이지
**페이지네이션:** 50/100/200/500/1,000/2,000 entries
#### 등록/수정 폼 (모달/팝업)
**폼 필드:**
| 필드명 | 타입 | 필수 | 설명 |
|--------|------|------|------|
| 등록일 | DatePicker | ✅ | 기본값: 오늘 |
| 형태 | 라디오 | ✅ | 스크린/철재 |
| 인정/비인정 | 라디오 | ✅ | 인정/비인정 |
| 절곡품 그룹 | Select | ✅ | 8개 옵션 |
| 품명 | 텍스트 입력 | ✅ | |
| 규격(가로*세로) | 텍스트 입력 | | |
| 재질 | Select | | EGI 1.15T, EGI 1.55T, SUS 1.2T, SUS 1.5T |
| 점검구 방향 | Select | | 양면/후면/밑면 점검구 (케이스 부품 전용) |
| 케이스 너비 | 숫자 입력 | | 케이스 부품 전용 |
| 케이스 높이 | 숫자 입력 | | 케이스 부품 전용 |
| 전면부 밑 치수 | 숫자 입력 | | 케이스 부품 전용 |
| 레일폭 | 숫자 입력 | | 케이스 부품 전용 |
| 작성자 | 텍스트 (자동) | ✅ | 로그인 사용자 |
| 품목 검색어 | 텍스트 입력 | | |
| 비고 | 텍스트 입력 | | |
**절곡 형상 입력 테이블 (동적 행):**
| 행 필드 | 설명 |
|---------|------|
| 번호 | 행 순번 (+/- 버튼) |
| 입력 | 폭 치수 입력 (노란 배경) |
| 연신율 | 연신율 값 |
| 연신율계산 후 | 자동 계산 (읽기전용, 회색) |
| 합계 | 누적 합계 (주황 배경) |
| 음영 | 체크박스 (역방향 표시) |
| A각 표시 | 체크박스 |
**하단 버튼:**
- `모든칸 비우기` | `마지막 열추가` | `마지막 열삭제`
**우측 패널:**
- `그리기` 버튼 → Canvas 기반 절곡 형상 드로잉
- 이미지 붙여넣기 (Ctrl+V) 영역
- 조회 모드에서는 그리기 비활성화
**상세 조회 모드 추가 버튼:**
- `수정` | `복사` | `삭제` | `닫기`
#### 구현 포인트
- **그리기 도구**: ✅ 기존 `DrawingCanvas` 재활용 (신규 구현 불필요)
- **전개도 섹션**: ✅ `BendingDiagramSection` 패턴 참조 (파일업로드/그리기 전환, 치수 테이블)
- **동적 행 관리**: 열 추가/삭제 + 자동 합계 계산 (BendingDiagramSection 로직 참조)
- **조건부 필드**: 절곡품 그룹이 "케이스"일 때만 케이스 관련 필드 표시
- **이미지 붙여넣기**: Clipboard API 활용
---
### 2-2. 재고생산/작업일지/중간검사성적서 (Bending LOT)
#### 목록 페이지
**필터 영역:**
| 필터명 | 타입 | 옵션 |
|--------|------|------|
| 품목명 | Select | 가이드레일(벽면형), 가이드레일(측면형), 연기차단재, 하단마감재(스크린), 하단마감재(철재), L-Bar, 케이스 |
| 종류명 | Select | 화이바원단, SUS(마감), SUS(마감)2, EGI(마감), 스크린용, D형, C형, 본체, 본체(철재), 후면코너부, 린텔부, 점검구, 전면부 |
| 모양&길이 | Select | W50×3000~4000, W80×3000~4000, 1219~4300 등 |
| 키워드 검색 | 텍스트 입력 | |
**테이블 컬럼:**
| 순서 | 컬럼명 | 설명 |
|------|--------|------|
| 1 | 번호 | 순번 |
| 2 | 등록일 | yyyy-MM-dd |
| 3 | 원자재 LOT | LOT 번호 (링크, 파란색) |
| 4 | 원단 LOT | LOT 번호 (링크, 파란색) |
| 5 | 생산 LOT | LOT 번호 (링크, 파란색) - 자동생성 규칙 있음 |
| 6 | 중간검사성적서 | PDF 아이콘 (클릭→PDF 보기/다운로드) |
| 7 | 품목명 | C(케이스), R(가이드레일) 등 약어+전체명 |
| 8 | 종류 | F(전면부), L(린텔부) 등 약어+전체명 |
| 9 | 모양&길이 | 40(4000) 형식 |
| 10 | 수량 | 숫자 |
| 11 | 작성 | 작성자 |
| 12 | 비고 | 메모 |
**액션 버튼:**
- `신규` → 등록 폼 팝업
- `업로드` → 일괄 업로드 (엑셀 등)
**LOT 번호 자동생성 규칙:**
- 형식: `{품목코드}{종류코드}{생산코드}{날짜}-{길이코드}`
- 예: `CF4A15-40` = C(케이스) + F(전면부) + 4(연도끝자리) + A(상반기) + 15(날짜) + 40(4000mm)
#### 구현 포인트
- **LOT 번호 자동생성**: 품목/종류/날짜 기반 규칙 엔진
- **중간검사성적서 PDF**: PDF 뷰어 통합 (미리보기/다운로드)
- **원자재/원단 LOT 연결**: LOT 선택 모달 (기존 수입검사 LOT 연계)
- **업로드 기능**: 엑셀 일괄 등록
---
### 2-3. 가이드레일 (Guiderail)
#### 목록 페이지
**필터 영역:**
| 필터명 | 타입 | 옵션 |
|--------|------|------|
| 대분류 | 토글 | 전체/스크린/철재 |
| 인정/비인정 | 토글 | 전체/인정/비인정 |
| 모델 선택 | Select | (모델 선택) - 동적 |
| 키워드 검색 | 텍스트 입력 | |
**테이블 컬럼:**
| 순서 | 컬럼명 | 설명 |
|------|--------|------|
| 1 | 번호 | 순번 |
| 2 | 등록일 | yyyy-MM-dd |
| 3 | 대분류 | 스크린/철재 |
| 4 | 인정/비인정 | |
| 5 | 제품코드 | KSS02, KQTS01 등 |
| 6 | 품목검색어 | |
| 7 | 가로(너비) X 세로(폭) | 치수 (링크, 파란색) |
| 8 | 형상 | 벽면형/측면형 (색상 구분) |
| 9 | 마감 | SUS마감/EGI마감 등 |
| 10 | 소요자재량 | SUS 1.2T(406) EGI 1.55T(398) 형식 |
| 11 | 형태 | 조립도 이미지 (썸네일) |
| 12 | 작업지시서 | `보기` 버튼 |
| 13 | 작성 | 작성자 |
| 14 | 비고 | 메모 |
**특수 액션 버튼:**
- `신규` → 등록 폼 팝업
- `결합형태 이미지 등록` → 조립 이미지 관리
- `형태별 기본 전개도` → 기본 전개도 조회/관리
**모달/팝업:**
1. **작업지시서 보기** → 인쇄 가능한 작업지시서 문서 (PDF 또는 프린트 뷰)
2. **결합형태 이미지** → 이미지 업로드/관리
3. **기본 전개도** → 형태별 전개도 이미지/설정
#### 구현 포인트
- **제품코드 체계**: 모델별 자동 코드 생성
- **소요자재량 계산**: 치수 기반 자동 산출
- **작업지시서**: 인쇄용 레이아웃 (기존 품질관리 문서 패턴 활용)
- **전개도 관리**: 형태(벽면형/측면형)별 기본 템플릿
---
### 2-4. 케이스 / 셔터박스 (Shutterbox)
#### 목록 페이지
**필터 영역:**
| 필터명 | 타입 | 옵션 |
|--------|------|------|
| 점검구 형태 | 토글 | 전체/양면 점검구/밑면 점검구/후면 점검구 |
| 키워드 검색 | 텍스트 입력 | |
**테이블 컬럼:**
| 순서 | 컬럼명 | 설명 |
|------|--------|------|
| 1 | 번호 | 순번 |
| 2 | 등록일 | yyyy-MM-dd |
| 3 | 박스(가로X세로) | 치수 (링크, 파란색) |
| 4 | 점검구 형태 | 양면/밑면/후면 점검구 (색상 구분) |
| 5 | 전면부 밑면 치수 | 숫자 |
| 6 | 레일(폭) | 숫자 |
| 7 | 소요자재량 | EGI 1.55T(2,652) 형식 |
| 8 | 품목 검색어 | |
| 9 | 형태 | 조립도 이미지 (가로세로 표기 포함) |
| 10 | 작업지시서 | `보기` 버튼 |
| 11 | 작성 | 작성자 |
| 12 | 비고 | 메모 |
**특수 액션 버튼:**
- `신규` → 등록 폼 팝업
- `결합형태 이미지 등록` → 조립 이미지 관리
- `점검구 형태별 기본 전개도` → 점검구 타입별 전개도 관리
#### 구현 포인트
- **박스 치수 기반 자동계산**: 가로×세로 입력 시 각 부품별 소요자재량 자동 산출
- **점검구 형태에 따른 전개도 차이**: 양면/밑면/후면 각각 다른 전개도 로직
- **조립도 이미지**: 치수 표기 포함된 SVG/Canvas 렌더링
---
### 2-5. 하단마감재 (Bottombar)
#### 목록 페이지
**필터 영역:**
| 필터명 | 타입 | 옵션 |
|--------|------|------|
| 대분류 | 토글 | 전체/스크린/철재 |
| 인정/비인정 | 토글 | 전체/인정/비인정 |
| 모델 선택 | Select | (모델 선택) |
| 키워드 검색 | 텍스트 입력 | |
**테이블 컬럼:**
| 순서 | 컬럼명 | 설명 |
|------|--------|------|
| 1 | 번호 | 순번 |
| 2 | 등록일 | yyyy-MM-dd |
| 3 | 대분류 | 스크린/철재 |
| 4 | 인정/비인정 | |
| 5 | 제품코드 | KSS01, KTE01 등 |
| 6 | 가로(폭) X 세로(높이) | 치수 (링크, 파란색) |
| 7 | 품목검색어 | |
| 8 | 마감형태 | SUS마감/EGI마감 (색상 강조) |
| 9 | 소요자재량 | 재질별 소요량 |
| 10 | 형태 | 전개도 이미지 (상세 치수 표기) |
| 11 | 작업지시서 | `보기` 버튼 |
| 12 | 작성 | 작성자 |
| 13 | 비고 | 메모 |
**액션 버튼:**
- `신규` → 등록 폼 팝업
- `이미지 등록` → 형태 이미지 관리
#### 구현 포인트
- 가이드레일과 구조가 유사 → 공통 컴포넌트 추출 가능
- 하단마감재 전용 전개도 로직
---
### 2-6. 절곡 재고현황 (Bending Stock)
#### 대시보드 형태 페이지 (목록이 아닌 집계 화면)
**필터 영역:**
| 필터명 | 타입 | 설명 |
|--------|------|------|
| 기간 시작 | DatePicker | 기본값: 2024.01.01 |
| 기간 종료 | DatePicker | 기본값: 오늘 |
| 품목명 | Select | |
| 종류명 | Select | |
| 모양&길이 | Select | |
| 키워드 검색 | 텍스트 입력 | |
| 그룹 선택 | 체크박스 그룹 | 전체선택/전체해제 + 가이드레일/케이스/하단마감재/기타 |
**전체 재고 요약 카드:**
| 항목 | 색상 | 설명 |
|------|------|------|
| 총 생산량 | 파랑 | 전체 생산 수량 합계 |
| 총 사용량 | 빨강 | 전체 사용(출고) 수량 합계 |
| 총 재고량 | 초록 | 생산량 - 사용량 |
**그룹별 재고 현황 테이블 (4개 섹션):**
1. 가이드레일 재고 현황
2. 케이스 재고 현황
3. 하단마감재 재고 현황
4. 기타 재고 현황
각 테이블 컬럼:
| 컬럼명 | 설명 |
|--------|------|
| 품목명 | 가이드레일(벽면형), 케이스 등 |
| 종류 | C형, D형, 본체 등 |
| 모양&길이 | 2438, 3000, 3500 등 |
| 생산량 | 숫자 |
| 품목코드 | RC24, RD30 등 |
| 사용량 | 숫자 |
| 재고량 | 숫자 (생산량-사용량) |
| 상태 | "재고있음" / "재고없음" / "부족" |
**액션 버튼:**
- `신규` → LOT 신규 등록
- `작업일지` → 작업일지 페이지 이동
- `업로드` → 일괄 업로드
#### 구현 포인트
- **집계 데이터**: 서버 사이드 집계 API 필요
- **그룹별 접기/펼치기**: Accordion 패턴
- **상태 색상 코딩**: 재고있음(녹색), 부족(노란색), 재고없음(빨간색)
- **체크박스 그룹 필터**: 실시간 테이블 섹션 토글
---
## 3. 공통 컴포넌트 / 패턴
### 3-1. 공통으로 추출할 컴포넌트
| 컴포넌트 | 사용처 | 설명 |
|---------|--------|------|
| `WorkOrderViewer` | 가이드레일, 케이스, 하단마감재 | 작업지시서 보기/인쇄 모달 |
| `BlueprintImageManager` | 가이드레일, 케이스 | 결합형태 이미지 업로드/관리 |
| `BendingFilterBar` | 바라시, 가이드레일, 하단마감재 | 대분류/인정 토글 + 모델 선택 필터 |
| `MaterialCalculation` | 가이드레일, 케이스, 하단마감재 | 소요자재량 표시 컴포넌트 |
| `LotNumberGenerator` | LOT 관리 | LOT 번호 자동생성 로직 |
### 3-2. SAM 기존 패턴 적용
| 요소 | SAM 패턴 | 적용 방법 |
|------|---------|---------|
| 목록 페이지 | IntegratedListTemplateV2 또는 UniversalListPage | 필터+테이블+페이지네이션 |
| 등록/수정 | 모달 팝업 (버디가 팝업 사용) | Dialog 기반 폼 |
| 필터 토글 | ToggleGroup (ui/) | 전체/스크린/철재 등 |
| Select 필터 | Select (ui/) | 중분류, 품명, 모델 등 |
| 테이블 | DataTable + 컬럼 설정 | useColumnSettings 적용 |
| PDF 보기 | 기존 PDF 뷰어 또는 새 창 | 중간검사성적서 |
| 날짜 | DatePicker (기존) | 등록일, 기간 필터 |
---
## 4. 백엔드 API 연동 예상
### 4-1. 필요 API 엔드포인트 (예상)
```
# 절곡 바라시 기초자료
GET /api/v1/bending ← 목록 조회 (필터/페이지네이션)
GET /api/v1/bending/{id} ← 상세 조회
POST /api/v1/bending ← 등록
PUT /api/v1/bending/{id} ← 수정
DELETE /api/v1/bending/{id} ← 삭제
POST /api/v1/bending/{id}/copy ← 복사
# 재고생산/LOT
GET /api/v1/bending-lot ← LOT 목록
GET /api/v1/bending-lot/{id} ← LOT 상세
POST /api/v1/bending-lot ← LOT 등록
PUT /api/v1/bending-lot/{id} ← LOT 수정
POST /api/v1/bending-lot/upload ← 일괄 업로드
GET /api/v1/bending-lot/{id}/inspection-report ← 중간검사성적서 PDF
# 가이드레일
GET /api/v1/guiderail ← 목록
GET /api/v1/guiderail/{id} ← 상세
POST /api/v1/guiderail ← 등록
PUT /api/v1/guiderail/{id} ← 수정
GET /api/v1/guiderail/{id}/work-order ← 작업지시서
GET /api/v1/guiderail/blueprints ← 기본 전개도
POST /api/v1/guiderail/blueprint-image ← 결합형태 이미지 등록
# 케이스 (셔터박스)
GET /api/v1/shutterbox ← 목록
GET /api/v1/shutterbox/{id} ← 상세
POST /api/v1/shutterbox ← 등록
PUT /api/v1/shutterbox/{id} ← 수정
GET /api/v1/shutterbox/{id}/work-order ← 작업지시서
GET /api/v1/shutterbox/blueprints ← 기본 전개도
POST /api/v1/shutterbox/blueprint-image ← 결합형태 이미지 등록
# 하단마감재
GET /api/v1/bottombar ← 목록
GET /api/v1/bottombar/{id} ← 상세
POST /api/v1/bottombar ← 등록
PUT /api/v1/bottombar/{id} ← 수정
GET /api/v1/bottombar/{id}/work-order ← 작업지시서
POST /api/v1/bottombar/image ← 이미지 등록
# 절곡 재고현황
GET /api/v1/bending-stock/summary ← 전체 재고 요약
GET /api/v1/bending-stock/by-group ← 그룹별 재고 현황
```
---
## 5. 구현 우선순위 및 단계
### Phase 1: 기초 인프라 + 단순 목록 (1주)
- [ ] 라우트 구조 생성 (6개 page.tsx)
- [ ] types.ts 정의 (전체 타입)
- [ ] actions.ts 기본 구조 (API 연동 준비)
- [ ] 하단마감재 목록/폼 (가장 단순, 11건)
- [ ] 가이드레일 목록/폼
### Phase 2: 핵심 기능 (1주)
- [ ] 케이스(셔터박스) 목록/폼
- [ ] 작업지시서 보기 공통 컴포넌트
- [ ] 결합형태 이미지 관리 공통 컴포넌트
- [ ] 기본 전개도 뷰어
### Phase 3: 바라시 기초자료 (3~5일) ← 기존 그리기 도구 재활용으로 단축
- [ ] 절곡 바라시 기초자료 목록
- [ ] 절곡 바라시 등록 폼 (동적 행 + 자동계산)
- [ ] 기존 DrawingCanvas 연동 (items/DrawingCanvas.tsx 그대로 import)
- [ ] 이미지 붙여넣기 기능 (Clipboard API)
### Phase 4: LOT + 재고 (1주)
- [ ] 재고생산/작업일지/중간검사성적서 목록/폼
- [ ] LOT 번호 자동생성 로직
- [ ] 중간검사성적서 PDF 연동
- [ ] 절곡 재고현황 대시보드
- [ ] 재고 요약 카드 + 그룹별 테이블
### Phase 5: 고도화 (선택)
- [ ] 절곡 모델설정 페이지
- [ ] 절곡 BOM 관리 페이지
- [ ] 엑셀 업로드/다운로드
- [ ] 인쇄 최적화
---
## 6. 리스크 및 주의사항
| 리스크 | 영향도 | 대응 |
|--------|--------|------|
| ~~그리기 도구 구현 복잡도~~ | ~~높음~~ | ✅ **해결**: 기존 DrawingCanvas 재활용 (품목관리에서 사용 중) |
| API 엔드포인트 미확정 | 중간 | Mock 데이터로 UI 선구현 후 API 연동 |
| LOT 번호 생성 규칙 확인 필요 | 중간 | 백엔드 팀과 규칙 확정 필요 |
| 전개도/조립도 이미지 관리 | 낮음 | R2 스토리지 + 기존 BendingDiagramSection 패턴 재활용 |
| 소요자재량 계산 로직 | 중간 | 백엔드 API에서 계산 후 반환 vs 프론트 계산 확정 필요 |
---
## 7. 확인 필요 사항 (백엔드 팀)
1. **API 엔드포인트 명명 규칙** 확정 (위 예상과 맞는지)
2. **LOT 번호 자동생성 규칙** 정확한 로직 (프론트 생성 vs 백엔드 생성)
3. **소요자재량 계산** 위치 (프론트 vs 백엔드)
4. **중간검사성적서 PDF** 생성/저장 방식
5. **작업지시서** 데이터 구조 및 생성 방식
6. **절곡 모델설정 / BOM** 별도 페이지 여부 및 구조
7. **이미지 저장** 경로 (R2 스토리지 활용?)

View File

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

View File

@@ -15,6 +15,7 @@ const eslintConfig = [
"node_modules/**", "node_modules/**",
"next-env.d.ts", "next-env.d.ts",
"src/components/_unused/**", // Archived unused components "src/components/_unused/**", // Archived unused components
"src/components/settings/AccountManagement/_legacy/**", // Legacy files
"src/hooks/useCurrentTime.ts", // Demo hook "src/hooks/useCurrentTime.ts", // Demo hook
], ],
}, },
@@ -76,9 +77,38 @@ const eslintConfig = [
HTMLTableCaptionElement: "readonly", HTMLTableCaptionElement: "readonly",
HTMLTextAreaElement: "readonly", HTMLTextAreaElement: "readonly",
HTMLCanvasElement: "readonly", HTMLCanvasElement: "readonly",
HTMLDivElement: "readonly",
HTMLElement: "readonly",
HTMLImageElement: "readonly",
ImageData: "readonly", ImageData: "readonly",
Image: "readonly", Image: "readonly",
prompt: "readonly", prompt: "readonly",
Audio: "readonly",
Blob: "readonly",
CSSStyleDeclaration: "readonly",
CustomEvent: "readonly",
Element: "readonly",
ErrorEvent: "readonly",
Event: "readonly",
FileList: "readonly",
FileReader: "readonly",
Headers: "readonly",
IntersectionObserver: "readonly",
KeyboardEvent: "readonly",
MouseEvent: "readonly",
Node: "readonly",
NodeJS: "readonly",
PromiseRejectionEvent: "readonly",
RequestCache: "readonly",
ResizeObserver: "readonly",
Storage: "readonly",
cancelAnimationFrame: "readonly",
crypto: "readonly",
getComputedStyle: "readonly",
google: "readonly",
navigator: "readonly",
requestAnimationFrame: "readonly",
sessionStorage: "readonly",
}, },
}, },
plugins: { plugins: {
@@ -95,7 +125,9 @@ const eslintConfig = [
"@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["error", { "@typescript-eslint/no-unused-vars": ["error", {
"argsIgnorePattern": "^_", "argsIgnorePattern": "^_",
"varsIgnorePattern": "^_" "varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_"
}], }],
}, },
}, },

View File

@@ -6,6 +6,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트 reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
allowedDevOrigins: ['dev.sam.kr', '192.168.0.*'], // 로컬 도메인 + 네트워크 기기 접속 허용
serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외 serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외
images: { images: {
remotePatterns: [ remotePatterns: [
@@ -28,6 +29,9 @@ const nextConfig: NextConfig = {
}, },
// Capacitor 패키지는 모바일 앱 전용 - 웹 빌드에서 제외 // Capacitor 패키지는 모바일 앱 전용 - 웹 빌드에서 제외
webpack: (config, { isServer }) => { webpack: (config, { isServer }) => {
// macOS 26 호환성: webpack 캐시 비활성화 (rename ENOENT 방지)
config.cache = false;
if (!isServer) { if (!isServer) {
config.resolve.fallback = { config.resolve.fallback = {
...config.resolve.fallback, ...config.resolve.fallback,

View File

@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbo", "dev": "node scripts/validate-next-cache.mjs && NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' next dev",
"build": "next build", "build": "next build",
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &", "build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
"start": "next start -H 0.0.0.0", "start": "next start -H 0.0.0.0",
@@ -56,6 +56,7 @@
"next": "^15.5.9", "next": "^15.5.9",
"next-intl": "^4.4.0", "next-intl": "^4.4.0",
"puppeteer": "^24.37.2", "puppeteer": "^24.37.2",
"qrcode.react": "^4.2.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-day-picker": "^9.11.1", "react-day-picker": "^9.11.1",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",

View File

@@ -0,0 +1,90 @@
# SAM ERP 프론트엔드 개발 가이드
> **대상**: SAM ERP 프론트엔드 신규/기존 개발자
> **최종 업데이트**: 2026-03-13
---
## 목차
| 문서 | 내용 |
|------|------|
| [00-overview.md](./00-overview.md) | 프로젝트 개요 및 기술 스택 (이 문서) |
| [01-project-structure.md](./01-project-structure.md) | 디렉토리 구조 및 파일 배치 규칙 |
| [02-routing-and-pages.md](./02-routing-and-pages.md) | 라우팅, 페이지 모드, 레이아웃 |
| [03-authentication.md](./03-authentication.md) | 인증 흐름, HttpOnly 쿠키, API 프록시 |
| [04-server-actions.md](./04-server-actions.md) | Server Action 패턴, API 통신 유틸리티 |
| [05-common-components.md](./05-common-components.md) | 공통 컴포넌트 (organisms, molecules, templates) |
| [06-ui-components.md](./06-ui-components.md) | UI 컴포넌트 카탈로그 |
| [07-hooks.md](./07-hooks.md) | 공통 Hooks |
| [08-utilities.md](./08-utilities.md) | 유틸리티 함수 (포맷터, URL 빌더, 인쇄 등) |
| [09-coding-conventions.md](./09-coding-conventions.md) | 코딩 컨벤션 및 필수 규칙 |
---
## 프로젝트 개요
```yaml
프로젝트: SAM ERP (통합 자원관리 시스템)
프론트엔드: Next.js 15 (App Router, TypeScript)
백엔드 API: PHP Laravel (sam-api)
특성: 인증 필수 폐쇄형 ERP (SEO 불필요, 크롤링 차단)
```
### 저장소 구조
```
sam_project/
├── sam-next/sma-next-project/sam-react-prod/ # Next.js 프론트엔드 (현재)
├── sam-api/sam-api/ # PHP Laravel 백엔드 API
├── sam-design/sam-design/ # React 디자인 시스템
└── sam-hotfix/sam-hotfix/ # E2E 테스트/핫픽스 관리
```
### 기술 스택
| 카테고리 | 기술 |
|----------|------|
| **프레임워크** | Next.js 15 (App Router, Turbopack) |
| **언어** | TypeScript (strict) |
| **스타일링** | Tailwind CSS |
| **UI 라이브러리** | Radix UI (shadcn/ui 기반) |
| **상태 관리** | Zustand |
| **폼 관리** | react-hook-form + Zod (신규 폼) |
| **차트** | Recharts |
| **국제화** | next-intl (ko, en, ja) |
| **토스트** | Sonner |
| **아이콘** | Lucide React |
| **날짜** | date-fns (한국어 locale) |
| **인증** | HttpOnly Cookie + API Proxy |
| **모바일** | Capacitor (하이브리드 앱) |
### 핵심 원칙
1. **모든 페이지는 Client Component** (`'use client'`) - 폐쇄형 ERP이므로 SEO 불필요, 서버 컴포넌트에서 쿠키 갱신 불가
2. **HttpOnly 쿠키 인증** - JavaScript에서 토큰 접근 불가, API Proxy 필수
3. **mode 쿼리파라미터** - `/new`, `/edit` 별도 경로 대신 `?mode=new`, `?mode=edit` 사용
4. **buildApiUrl 필수** - URL 직접 조립 금지
5. **컴포넌트 재사용** - 새 컴포넌트 전 기존 컴포넌트 검색 필수
### 개발 환경
```bash
# 로컬 개발 서버
npm run dev
# 타입 체크
npx tsc --noEmit
# 빌드 (로컬 확인용)
npm run build
```
### 브랜치 전략
| 브랜치 | 역할 |
|--------|------|
| `develop` | 평소 작업 브랜치 (자유롭게 커밋) |
| `stage` | QA/테스트 환경 |
| `main` | 배포용 (기능별 squash merge) |
| `feature/*` | 큰 기능/실험적 작업 |

View File

@@ -0,0 +1,112 @@
# 프로젝트 구조 및 파일 배치 규칙
## 디렉토리 구조
```
src/
├── app/ # Next.js App Router
│ ├── [locale]/ # i18n (ko, en, ja)
│ │ ├── (auth)/ # 인증 페이지 (로그인 등)
│ │ │ └── login/
│ │ ├── (protected)/ # 보호된 라우트 (인증 필수)
│ │ │ ├── accounting/ # 회계
│ │ │ ├── approval/ # 전자결재
│ │ │ ├── board/ # 게시판
│ │ │ ├── construction/ # 시공
│ │ │ ├── customer-center/ # 고객센터
│ │ │ ├── dashboard/ # 대시보드
│ │ │ ├── hr/ # 인사
│ │ │ ├── master-data/ # 기준정보
│ │ │ ├── material/ # 자재
│ │ │ ├── outbound/ # 출고
│ │ │ ├── production/ # 생산
│ │ │ ├── quality/ # 품질
│ │ │ ├── reports/ # 리포트
│ │ │ ├── sales/ # 영업
│ │ │ ├── settings/ # 설정
│ │ │ ├── [...slug]/ # catch-all (미구현 메뉴)
│ │ │ └── layout.tsx # 보호 레이아웃
│ │ ├── layout.tsx # 루트 레이아웃 (i18n)
│ │ └── page.tsx # / → /dashboard 리다이렉트
│ └── api/ # API Routes
│ ├── proxy/[...path]/ # HttpOnly 쿠키 프록시
│ ├── auth/ # 인증 엔드포인트
│ └── pdf/generate/ # PDF 생성
├── components/
│ ├── ui/ # 기본 UI 컴포넌트 (shadcn/ui 기반)
│ ├── molecules/ # 조합 컴포넌트 (FormField, StatusBadge 등)
│ ├── organisms/ # 페이지 구성 블록 (PageHeader, DataTable 등)
│ ├── templates/ # 페이지 템플릿 (IntegratedListTemplateV2 등)
│ ├── layout/ # 레이아웃 컴포넌트 (Sidebar, Header 등)
│ └── {domain}/ # 도메인별 컴포넌트
│ ├── accounting/
│ ├── hr/
│ ├── production/
│ ├── quality/
│ └── ...
├── hooks/ # 공통 Hooks
├── layouts/ # AuthenticatedLayout
├── lib/ # 유틸리티
│ ├── api/ # API 통신 유틸리티
│ ├── auth/ # 인증 유틸리티
│ ├── formatters.ts # 포맷팅 함수
│ ├── print-utils.ts # 인쇄 유틸리티
│ └── utils.ts # 기본 유틸리티 (cn 등)
├── stores/ # Zustand 스토어
├── i18n/ # 국제화 설정
├── types/ # 공통 타입 정의
└── styles/ # 글로벌 스타일
```
## 컴포넌트 계층
```
ui/ → 원자 컴포넌트 (Button, Input, Select ...)
molecules/ → 조합 컴포넌트 (FormField = Label + Input + Error)
organisms/ → 페이지 빌딩 블록 (PageHeader, DataTable, SearchFilter ...)
templates/ → 페이지 전체 템플릿 (IntegratedListTemplateV2)
{domain}/ → 도메인 전용 컴포넌트 (AccountingForm, QualityReport ...)
```
## 파일 배치 규칙
### 도메인 컴포넌트
```
src/components/{domain}/
├── {Feature}List.tsx # 목록 컴포넌트
├── {Feature}Detail.tsx # 상세/수정/등록 컴포넌트
├── {Feature}Modal.tsx # 모달 컴포넌트
└── actions.ts # Server Actions
```
### 페이지 파일
```
src/app/[locale]/(protected)/{domain}/{feature}/
├── page.tsx # 목록 + mode=new 분기
├── [id]/
│ └── page.tsx # 상세 + mode=edit 분기
└── actions.ts # (또는 components/{domain}/actions.ts)
```
### Server Actions 위치
- **우선**: `src/components/{domain}/actions.ts` (도메인별)
- **대안**: `src/app/[locale]/(protected)/{domain}/{feature}/actions.ts` (페이지별)
## 파일 네이밍
| 유형 | 네이밍 | 예시 |
|------|--------|------|
| 컴포넌트 | PascalCase | `VendorDetail.tsx` |
| 페이지 | `page.tsx` (Next.js 규칙) | `page.tsx` |
| Server Action | `actions.ts` | `actions.ts` |
| 유틸리티 | kebab-case | `query-params.ts` |
| Hook | camelCase + `use` 접두사 | `useColumnSettings.ts` |
| 타입 | `types.ts` 또는 인라인 | `types.ts` |
| 스키마 | `schema.ts` | `schema.ts` |
## 신규 파일 생성 전 체크리스트
- [ ] 유사 컴포넌트가 이미 있는지 `organisms/`, `molecules/` 확인
- [ ] 같은 도메인에 재사용 가능한 컴포넌트 확인
- [ ] dev/component-registry 페이지에서 검색
- [ ] 공통 UI 컴포넌트(`ui/`)로 해결 가능한지 확인

View File

@@ -0,0 +1,159 @@
# 라우팅 및 페이지 패턴
## 라우팅 구조
```
/[locale]/(auth)/login # 로그인
/[locale]/(protected)/dashboard # 대시보드
/[locale]/(protected)/{domain}/{feature} # 목록
/[locale]/(protected)/{domain}/{feature}?mode=new # 등록
/[locale]/(protected)/{domain}/{feature}/[id] # 상세(view)
/[locale]/(protected)/{domain}/{feature}/[id]?mode=edit # 수정
```
## 레이아웃 계층
```
Root Layout ([locale]/layout.tsx) - Server Component
├── i18n 설정 (NextIntlClientProvider)
├── 폰트 로드 (PretendardVariable)
├── Toaster (sonner)
└── Protected Layout ((protected)/layout.tsx) - Client Component
├── useAuthGuard() - 인증 보호
├── RootProvider - 전역 상태
├── ApiErrorProvider - 401 에러 처리
├── FCMProvider - 푸시 알림
├── PermissionGate - 권한 제어
└── AuthenticatedLayout
├── Sidebar - 메뉴
├── Header - 회사선택, 검색, 알림
├── HeaderFavoritesBar - 즐겨찾기
└── {children} - 페이지 컨텐츠
```
## 페이지 모드 패턴 (mode 쿼리파라미터)
### 규칙
- **별도 `/new`, `/edit` 경로 금지** → `?mode=new`, `?mode=edit` 사용
- 목록과 등록을 **같은 page.tsx에서 분기**
### 목록 + 등록 (page.tsx)
```tsx
'use client';
import { useSearchParams } from 'next/navigation';
export default function ItemsPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
// mode=new → 등록 폼
if (mode === 'new') {
return <ItemDetail mode="new" />;
}
// 기본 → 목록
return <ItemList />;
}
```
### 상세 + 수정 ([id]/page.tsx)
```tsx
'use client';
import { useParams, useSearchParams } from 'next/navigation';
export default function ItemDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const id = params.id as string;
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
return <ItemDetail id={id} mode={mode} />;
}
```
### 네비게이션
```tsx
// 목록 → 등록
router.push('/master-data/items?mode=new');
// 목록 → 상세
router.push(`/master-data/items/${id}`);
// 상세 → 수정
router.push(`/master-data/items/${id}?mode=edit`);
// 수정 → 상세 (저장 후)
router.push(`/master-data/items/${id}`);
// → 목록으로
router.push('/master-data/items');
```
## 페이지 레이아웃 표준
### PageLayout 패딩 규칙
- `AuthenticatedLayout``<main>`에는 패딩 없음
- `PageLayout` 컴포넌트가 `p-3 md:p-6` 패딩 담당
- **page.tsx에서 패딩 wrapper 추가 금지** (이중 패딩 방지)
### 등록/수정/상세 페이지 헤더
```tsx
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold">페이지 제목</h1>
<Button variant="link" className="text-muted-foreground"
onClick={() => router.push(listPath)}>
목록으로
</Button>
</div>
```
### 하단 Sticky 액션 바 (필수)
폼 페이지 하단에 sticky bar로 버튼 배치:
| 모드 | 좌측 | 우측 |
|------|------|------|
| 등록 (new) | `X 취소` | `💾 저장` |
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
| 수정 (edit) | `X 취소` | `💾 저장` |
```tsx
<div className="sticky bottom-0 bg-white border-t shadow-sm">
<div className="px-3 py-3 md:px-6 md:py-4 flex items-center justify-between">
<Button variant="outline" onClick={() => router.push(listPath)}>
<X className="h-4 w-4 mr-1" />
취소
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
: <Save className="h-4 w-4 mr-1" />}
저장
</Button>
</div>
</div>
```
## 테이블 표준
### 필수 컬럼 구조
**체크박스** → **번호(1부터)****데이터 컬럼****작업 컬럼**
```tsx
// 번호 계산 (페이지네이션 고려)
const globalIndex = (currentPage - 1) * pageSize + index + 1;
```
### 작업 버튼
- 체크박스 선택 시에만 표시
## i18n
```
지원 언어: ko (기본), en, ja
경로: /ko/..., /en/..., /ja/...
```

View File

@@ -0,0 +1,137 @@
# 인증 및 API 통신
## 인증 아키텍처
SAM ERP는 **HttpOnly Cookie 기반 인증**을 사용합니다. JavaScript에서 토큰을 직접 접근할 수 없으므로, 모든 인증 API 호출은 **Next.js API Proxy**를 통해 처리됩니다.
```
클라이언트 (브라우저)
├── Server Action 호출 (useEffect에서)
│ └── serverFetch() → 서버에서 쿠키 읽기 → 백엔드 API 호출
└── 프록시 API 호출 (fetch('/api/proxy/...'))
└── API Proxy Route → 서버에서 쿠키 읽기 → 백엔드 API 호출
```
## 쿠키 구조
| 쿠키명 | HttpOnly | 용도 | Max-Age |
|--------|:--------:|------|---------|
| `access_token` | O | API 인증 토큰 | 2시간 |
| `refresh_token` | O | 토큰 갱신용 | 7일 |
| `is_authenticated` | X | 클라이언트 인증 상태 확인 | 2시간 |
- `access_token`, `refresh_token`: HttpOnly → JavaScript 접근 불가 (XSS 방지)
- `is_authenticated`: non-HttpOnly → 클라이언트에서 인증 상태 확인 가능 (FCM 등)
- 프로덕션: `Secure` 플래그 활성화 (HTTPS만)
- `SameSite=Lax`: CSRF 방지
## 인증 흐름
### 로그인
```
1. 사용자 → /api/auth/login (POST)
2. 백엔드 → access_token + refresh_token 반환
3. API Route → Set-Cookie (HttpOnly) 설정
4. 클라이언트 → /(protected)/dashboard 리다이렉트
```
### API 요청 (Server Action)
```
1. 클라이언트 → Server Action 호출
2. serverFetch() → 쿠키에서 access_token 읽기
3. authenticatedFetch() → Authorization 헤더에 토큰 추가
4. 백엔드 API 호출 → 응답 반환
```
### 토큰 만료 시 (401 자동 갱신)
```
1. API 요청 → 401 응답 수신
2. authenticatedFetch() → refresh_token으로 갱신 요청
3. 새 토큰 수신 → 쿠키 업데이트
4. 원래 요청 재시도 → 성공
5. 갱신 실패 → 쿠키 삭제 → /login 리다이렉트
```
### 토큰 갱신 중복 방지
```typescript
// globalThis 레벨 캐싱 (5초)
// 여러 요청이 동시에 401을 받아도 refresh는 1회만 실행
// 진행 중인 refresh Promise를 공유하여 대기
```
## API 프록시 (`/api/proxy/[...path]`)
클라이언트에서 직접 백엔드 API를 호출해야 하는 경우 프록시 사용:
```typescript
// 클라이언트에서 프록시 호출
const response = await fetch('/api/proxy/item-master/init');
const data = await response.json();
```
프록시 내부 동작:
1. HttpOnly 쿠키에서 `access_token` 읽기
2. 백엔드 URL 구성 (`/api/proxy/*` → 백엔드 `/*`)
3. `Authorization: Bearer {token}` 헤더 추가
4. 요청 전달 → 응답 반환
5. 401 시 자동 토큰 갱신 후 재시도
6. 새 토큰 → Set-Cookie 헤더로 클라이언트에 전달
## 인증 보호
### Protected Layout
```tsx
// (protected)/layout.tsx
export default function ProtectedLayout({ children }) {
// 인증 가드 (뒤로가기 캐시 감지)
useAuthGuard();
return (
<RootProvider>
<ApiErrorProvider> {/* 401 에러 자동 처리 */}
<FCMProvider> {/* 푸시 알림 */}
<AuthenticatedLayout>
<PermissionGate> {/* 권한 기반 접근 제어 */}
{children}
</PermissionGate>
</AuthenticatedLayout>
</FCMProvider>
</ApiErrorProvider>
</RootProvider>
);
}
```
### 인증 상태 확인 (클라이언트)
```typescript
import { hasAuthToken } from '@/lib/api/auth-headers';
// is_authenticated 쿠키 확인 (non-HttpOnly)
if (hasAuthToken()) {
// 인증됨
}
```
## 로그아웃
완전한 로그아웃 절차:
1. Zustand 스토어 초기화 (useAuthStore, useMasterDataStore, useItemMasterStore)
2. sessionStorage 캐시 삭제 (page_config_*, mes-*)
3. localStorage 사용자 데이터 삭제
4. FCM 토큰 해제 (Capacitor 환경)
5. 서버 로그아웃 API 호출
6. /login 리다이렉트
## 주의사항
- **Server Component에서 쿠키 수정 불가** → Client Component 사용 필수
- **`alert()`, `confirm()`, `prompt()` 사용 금지** → Radix UI Dialog 또는 `toast` 사용
- **API 직접 호출 금지** → 반드시 Server Action 또는 프록시 사용

View File

@@ -0,0 +1,245 @@
# Server Action 패턴
## 개요
모든 백엔드 API 호출은 Server Action을 통해 처리합니다. 공통 유틸리티를 사용하여 보일러플레이트를 제거하고 일관된 패턴을 유지합니다.
## 핵심 유틸리티
### buildApiUrl - URL 빌더 (필수)
```typescript
import { buildApiUrl } from '@/lib/api/query-params';
// 기본 사용
buildApiUrl('/api/v1/items')
// → "https://api.example.com/api/v1/items"
// 쿼리 파라미터
buildApiUrl('/api/v1/items', {
search: 'test',
status: 'active',
page: 1,
})
// → "https://api.example.com/api/v1/items?search=test&status=active&page=1"
// undefined/null/'' 자동 필터링
buildApiUrl('/api/v1/items', {
search: '', // 제외됨
status: undefined, // 제외됨
page: 1,
})
// → "https://api.example.com/api/v1/items?page=1"
// 동적 경로 + 파라미터
buildApiUrl(`/api/v1/items/${id}`, { with_details: true })
```
> **금지**: `new URLSearchParams()` 직접 사용, `${API_URL}` 직접 조립
### executeServerAction - 단건/목록 조회
```typescript
import { executeServerAction } from '@/lib/api/execute-server-action';
const result = await executeServerAction<ApiType, FrontendType>({
url: buildApiUrl('/api/v1/items', { search: params.search }),
method: 'GET', // 기본값: GET
transform: (data) => ..., // snake_case → camelCase 변환
errorMessage: '조회에 실패했습니다.',
});
// 반환 타입
interface ActionResult<T> {
success: boolean;
data?: T;
error?: string;
fieldErrors?: Record<string, string[]>; // Laravel validation errors
__authError?: boolean; // 401 감지
}
```
### executePaginatedAction - 페이지네이션 조회
```typescript
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
const result = await executePaginatedAction<ApiType, FrontendType>({
url: buildApiUrl('/api/v1/items', {
search: params.search,
status: params.status !== 'all' ? params.status : undefined,
page: params.page,
}),
transform: transformApiToFrontend, // 개별 아이템 변환 함수
errorMessage: '목록 조회에 실패했습니다.',
});
// 반환 타입
interface PaginatedActionResult<T> {
success: boolean;
data: T[]; // 변환된 아이템 배열
pagination: PaginationMeta; // 페이지네이션 정보
error?: string;
__authError?: boolean;
}
interface PaginationMeta {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
}
```
## Server Action 작성 패턴
### 표준 예시
```typescript
// src/components/{domain}/actions.ts
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import { buildApiUrl } from '@/lib/api/query-params';
// ===== 1. API 원본 타입 (snake_case) =====
interface ItemApi {
id: number;
item_name: string;
item_code: string;
created_at: string;
}
// ===== 2. 프론트엔드 타입 (camelCase) =====
export interface Item {
id: string;
itemName: string;
itemCode: string;
createdAt: string;
}
// ===== 3. Transform 함수 =====
function transformItem(api: ItemApi): Item {
return {
id: String(api.id),
itemName: api.item_name,
itemCode: api.item_code,
createdAt: api.created_at,
};
}
// ===== 4. 목록 조회 (페이지네이션) =====
export async function getItems(params: {
search?: string;
status?: string;
page?: number;
}) {
return executePaginatedAction({
url: buildApiUrl('/api/v1/items', {
search: params.search,
status: params.status !== 'all' ? params.status : undefined,
page: params.page,
}),
transform: transformItem,
errorMessage: '품목 목록 조회에 실패했습니다.',
});
}
// ===== 5. 단건 조회 =====
export async function getItem(id: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/items/${id}`),
transform: (data: { item: ItemApi }) => transformItem(data.item),
errorMessage: '품목 조회에 실패했습니다.',
});
}
// ===== 6. 생성 =====
export async function createItem(formData: Partial<Item>) {
return executeServerAction({
url: buildApiUrl('/api/v1/items'),
method: 'POST',
body: {
item_name: formData.itemName,
item_code: formData.itemCode,
},
errorMessage: '품목 등록에 실패했습니다.',
});
}
// ===== 7. 수정 =====
export async function updateItem(id: string, formData: Partial<Item>) {
return executeServerAction({
url: buildApiUrl(`/api/v1/items/${id}`),
method: 'PUT',
body: {
item_name: formData.itemName,
item_code: formData.itemCode,
},
errorMessage: '품목 수정에 실패했습니다.',
});
}
// ===== 8. 삭제 =====
export async function deleteItems(ids: string[]) {
return executeServerAction({
url: buildApiUrl('/api/v1/items/bulk-delete'),
method: 'POST',
body: { ids: ids.map(Number) },
errorMessage: '품목 삭제에 실패했습니다.',
});
}
```
## 컴포넌트에서 Server Action 호출
```tsx
'use client';
import { useEffect, useState } from 'react';
import { getItems, type Item } from '@/components/{domain}/actions';
export default function ItemList() {
const [data, setData] = useState<Item[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
getItems({ page: 1 })
.then(result => {
if (result.success) {
setData(result.data);
}
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>로딩 ...</div>;
return <>{/* 렌더링 */}</>;
}
```
## 주의사항
### 'use server' 파일에서 타입 re-export 금지
```typescript
// ❌ 금지 - Next.js Turbopack 제한 (async 함수만 export 허용)
export type { Item } from './types';
export { type Item } from './types';
// ✅ 허용 - 인라인 타입 정의
export interface Item { ... }
export type Item = { ... };
// ✅ 허용 - 컴포넌트에서 원본 타입 파일 직접 import
// 컴포넌트에서: import type { Item } from './types';
```
### 데이터 변환 체인
```
Backend (snake_case) → safeResponseJson() → transform() → Frontend (camelCase)
```
- `safeResponseJson`: PHP 백엔드가 JSON 뒤에 경고 텍스트를 붙여 보내는 경우 방어
- `transform`: snake_case → camelCase 변환 (개발자 작성)

View File

@@ -0,0 +1,346 @@
# 공통 컴포넌트 가이드
## 컴포넌트 계층 요약
```
Templates → 페이지 전체 (IntegratedListTemplateV2)
Organisms → 페이지 블록 (PageHeader, DataTable, SearchFilter ...)
Molecules → 조합 단위 (FormField, StatusBadge, StandardDialog ...)
UI → 원자 단위 (Button, Input, Select ...)
```
---
## Templates
### IntegratedListTemplateV2
리스트 페이지를 위한 **올인원 템플릿**. 새 리스트 페이지 생성 시 이 템플릿 사용을 우선 검토합니다.
**경로**: `src/components/templates/IntegratedListTemplateV2.tsx`
**포함 기능**:
- PageLayout + PageHeader (아이콘/제목/설명)
- 검색 + 필터 + 날짜 선택 헤더
- 통계 카드 (StatCards)
- 테이블 + 컬럼 설정 + 페이지네이션
- 모바일 카드 자동 전환 (반응형)
- 체크박스 선택 (`Set<string>`)
**필수 적용 항목**:
1. 컬럼 설정 (`useColumnSettings` + `ColumnSettingsPopover`)
2. 모바일 카드 (`renderMobileCard`)
3. 체크박스 (`selectedItems: Set<string>`)
4. 테이블 내 필터 (`tableHeaderActions`)
**기본 사용법**:
```tsx
import IntegratedListTemplateV2 from '@/components/templates/IntegratedListTemplateV2';
import { useColumnSettings } from '@/hooks/useColumnSettings';
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
import { MobileCard, InfoField } from '@/components/organisms';
const columns = [
{ key: 'itemName', label: '품목명', width: '200px' },
{ key: 'itemCode', label: '품목코드', width: '150px' },
{ key: 'status', label: '상태', width: '100px' },
];
export default function ItemListPage() {
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const { visibleColumns, allColumnsWithVisibility, columnWidths, setColumnWidth,
toggleColumnVisibility, resetSettings, hasHiddenColumns } =
useColumnSettings({ pageId: 'item-list', columns });
return (
<IntegratedListTemplateV2
title="품목 관리"
icon={Package}
description="품목 목록을 관리합니다"
// 검색
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="품목명 또는 코드로 검색"
// 테이블
tableColumns={visibleColumns}
columnSettings={{
columnWidths,
onColumnResize: setColumnWidth,
settingsPopover: (
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
),
}}
data={items}
// 체크박스
selectedItems={selectedItems}
onToggleSelection={(id) => {
setSelectedItems(prev => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}}
onToggleSelectAll={() => { /* 전체 선택/해제 */ }}
getItemId={(item) => item.id}
// 테이블 행
renderTableRow={(item, index, globalIndex, isSelected, onToggle) => (
<tr key={item.id} className={isSelected ? 'bg-blue-50' : ''}>
<td><Checkbox checked={isSelected} onCheckedChange={onToggle} /></td>
<td>{globalIndex}</td>
<td>{item.itemName}</td>
<td>{item.itemCode}</td>
</tr>
)}
// 모바일 카드 (반응형)
renderMobileCard={(item, index, globalIndex, isSelected, onToggle) => (
<MobileCard
title={item.itemName}
subtitle={item.itemCode}
isSelected={isSelected}
onToggleSelection={onToggle}
details={[
{ label: '상태', value: item.status },
]}
/>
)}
// 페이지네이션
pagination={{
currentPage: pagination.currentPage,
totalPages: pagination.lastPage,
totalItems: pagination.total,
itemsPerPage: pagination.perPage,
onPageChange: (page) => fetchData({ page }),
}}
isLoading={isLoading}
// 등록 버튼
createButton={{ label: '품목 등록', onClick: () => router.push('?mode=new') }}
/>
);
}
```
---
## Organisms
**경로**: `src/components/organisms/`
**import**: `import { PageHeader, DataTable, ... } from '@/components/organisms'`
### PageHeader
```tsx
<PageHeader
title="품목 관리"
description="품목 목록을 관리합니다"
icon={Package}
actions={<Button onClick={handleCreate}>등록</Button>}
/>
```
| Prop | 타입 | 설명 |
|------|------|------|
| `title` | string \| ReactNode | 페이지 제목 (필수) |
| `description?` | string | 부제목 |
| `icon?` | LucideIcon | 좌측 아이콘 |
| `actions?` | ReactNode | 우측 액션 버튼 |
### PageLayout
```tsx
<PageLayout maxWidth="full">
{children}
</PageLayout>
```
| Prop | 타입 | 기본값 | 설명 |
|------|------|--------|------|
| `maxWidth?` | "sm"\|"md"\|"lg"\|"xl"\|"2xl"\|"full" | "full" | 최대 너비 |
### StatCards
```tsx
<StatCards stats={[
{ label: '전체', value: 100, icon: Package },
{ label: '활성', value: 80, icon: CheckCircle, iconColor: 'text-green-500' },
{ label: '비활성', value: 20, icon: XCircle, iconColor: 'text-red-500' },
]} />
```
### SearchFilter
```tsx
<SearchFilter
searchValue={search}
onSearchChange={setSearch}
searchPlaceholder="검색어 입력"
extraActions={<DatePicker value={date} onChange={setDate} />}
/>
```
### DataTable
```tsx
<DataTable
columns={[
{ key: 'name', label: '이름', sortable: true },
{ key: 'status', label: '상태', type: 'badge' },
{ key: 'amount', label: '금액', type: 'currency', align: 'right' },
{ key: 'actions', label: '', type: 'custom',
render: (_, row) => <Button size="sm">수정</Button> },
]}
data={items}
keyField="id"
onRowClick={(row) => router.push(`/items/${row.id}`)}
pagination={{ currentPage, totalPages, onPageChange }}
/>
```
**Column type 종류**: `text`, `number`, `currency`, `date`, `datetime`, `status`, `badge`, `icon`, `actions`, `custom`
### SearchableSelectionModal
검색+선택 팝업이 필요할 때 사용. **직접 Dialog 조합 금지**.
```tsx
<SearchableSelectionModal<Vendor>
open={isOpen}
onOpenChange={setIsOpen}
title="거래처 검색"
fetchData={async (query) => {
const result = await searchVendors({ search: query });
return result.success ? result.data : [];
}}
keyExtractor={(vendor) => vendor.id}
mode="single"
onSelect={(vendor) => handleVendorSelect(vendor)}
searchPlaceholder="거래처명으로 검색"
renderItem={(vendor, isSelected) => (
<div className={cn('p-3', isSelected && 'bg-blue-50')}>
<div className="font-medium">{vendor.name}</div>
<div className="text-sm text-muted-foreground">{vendor.code}</div>
</div>
)}
/>
```
| Prop | 필수 | 설명 |
|------|:---:|------|
| `open` | O | 모달 열기 상태 |
| `onOpenChange` | O | 상태 변경 |
| `title` | O | 모달 제목 |
| `fetchData` | O | `(query: string) => Promise<T[]>` |
| `keyExtractor` | O | `(item: T) => string` |
| `mode` | O | `'single'` \| `'multiple'` |
| `onSelect` | O | 선택 콜백 |
| `renderItem` | O | 아이템 렌더링 |
| `searchMode?` | | `'debounce'`(기본) \| `'enter'` |
| `loadOnOpen?` | | 열릴 때 자동 로드 |
| `listWrapper?` | | 리스트 래퍼 (테이블 구조 등) |
### MobileCard / InfoField
```tsx
<MobileCard
title="품목A"
subtitle="P-001"
isSelected={isSelected}
onToggleSelection={onToggle}
details={[
{ label: '규격', value: '100x200' },
{ label: '단가', value: '10,000원' },
]}
onClick={() => router.push(`/items/${item.id}`)}
/>
```
### EmptyState / TableEmptyState
```tsx
<EmptyState message="데이터가 없습니다" />
<TableEmptyState colSpan={5} message="검색 결과가 없습니다" />
```
---
## Molecules
**경로**: `src/components/molecules/`
### FormField (신규 폼 필수)
`Label + Input + Error` 수동 조합 대신 사용.
```tsx
import { FormField } from '@/components/molecules/FormField';
<FormField
label="회사명"
required
type="text"
value={formData.companyName}
onChange={(value) => handleChange('companyName', value)}
placeholder="회사명을 입력하세요"
disabled={mode === 'view'}
error={errors.companyName}
/>
```
**지원 type**: `text`, `number`, `date`, `select`, `textarea`, `custom`, `password`, `phone`, `businessNumber`, `personalNumber`, `currency`, `quantity`
**FormField로 대체하지 않는 경우**:
- Select, DatePicker, ImageUpload 등 특수 컴포넌트
- 주소 검색(버튼+입력) 등 복합 레이아웃
- 편집/읽기 모드가 다른 커스텀 인터랙션
### StatusBadge
```tsx
import { StatusBadge } from '@/components/molecules/StatusBadge';
<StatusBadge label="승인" variant="success" />
<StatusBadge label="대기" variant="warning" showDot />
<StatusBadge label="반려" variant="danger" />
```
**variant**: `default`, `success`, `warning`, `danger`, `info`, `secondary`, `outline`
### ColumnSettingsPopover
`useColumnSettings` hook과 함께 사용:
```tsx
<ColumnSettingsPopover
columns={allColumnsWithVisibility}
onToggle={toggleColumnVisibility}
onReset={resetSettings}
hasHiddenColumns={hasHiddenColumns}
/>
```
### StandardDialog
```tsx
<StandardDialog
open={isOpen}
onOpenChange={setIsOpen}
title="확인"
description="정말 삭제하시겠습니까?"
size="md"
footer={
<>
<Button variant="outline" onClick={() => setIsOpen(false)}>취소</Button>
<Button variant="destructive" onClick={handleDelete}>삭제</Button>
</>
}
>
<p> 작업은 되돌릴 없습니다.</p>
</StandardDialog>
```
**size**: `sm`, `md`, `lg`, `xl`, `full`

View File

@@ -0,0 +1,176 @@
# UI 컴포넌트 카탈로그
**경로**: `src/components/ui/`
**기반**: shadcn/ui (Radix UI + Tailwind CSS)
---
## 입력 컴포넌트
### 기본 입력
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Input` | `input.tsx` | 텍스트 입력 |
| `Textarea` | `textarea.tsx` | 여러 줄 텍스트 |
| `Checkbox` | `checkbox.tsx` | 체크박스 |
| `RadioGroup` | `radio-group.tsx` | 라디오 버튼 |
| `Switch` | `switch.tsx` | 토글 스위치 |
| `Slider` | `slider.tsx` | 슬라이더 |
| `Select` | `select.tsx` | 셀렉트 (Radix UI) |
### 특화 입력
| 컴포넌트 | 파일 | 용도 | 특징 |
|----------|------|------|------|
| `DatePicker` | `date-picker.tsx` | 날짜 선택 | 한글 locale, 주말/휴일 색상, 연/월 선택, "오늘" 버튼 |
| `DateRangePicker` | `date-range-picker.tsx` | 기간 선택 | 시작~종료 날짜 |
| `DateTimePicker` | `date-time-picker.tsx` | 날짜+시간 | |
| `TimePicker` | `time-picker.tsx` | 시간만 | |
| `PhoneInput` | `phone-input.tsx` | 전화번호 | 자동 하이픈 (010-1234-5678) |
| `BusinessNumberInput` | `business-number-input.tsx` | 사업자번호 | 자동 포맷 (000-00-00000) |
| `PersonalNumberInput` | `personal-number-input.tsx` | 주민번호 | 마스킹 가능 |
| `CardNumberInput` | `card-number-input.tsx` | 카드번호 | 4자리 구분 |
| `AccountNumberInput` | `account-number-input.tsx` | 계좌번호 | |
| `NumberInput` | `number-input.tsx` | 숫자 | |
| `CurrencyInput` | `currency-input.tsx` | 금액 | 천단위 콤마, ₩ 접두사 |
| `QuantityInput` | `quantity-input.tsx` | 수량 | +/- 버튼 |
| `FileInput` | `file-input.tsx` | 파일 | |
| `FileDropzone` | `file-dropzone.tsx` | 파일 드래그 앤 드롭 | |
| `ImageUpload` | `image-upload.tsx` | 이미지 업로드 | 미리보기 |
### 검색/선택
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `SearchableSelect` | `searchable-select.tsx` | 검색 가능 셀렉트 |
| `MultiSelectCombobox` | `multi-select-combobox.tsx` | 다중 선택 콤보박스 |
| `Command` | `command.tsx` | 검색/필터 커맨드 팔레트 |
---
## 피드백 컴포넌트
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Button` | `button.tsx` | 버튼 (variant: default, destructive, outline, secondary, ghost, link) |
| `Badge` | `badge.tsx` | 뱃지 |
| `Alert` | `alert.tsx` | 알림 |
| `AlertDialog` | `alert-dialog.tsx` | 알림 다이얼로그 |
| `ConfirmDialog` | `confirm-dialog.tsx` | 확인 다이얼로그 |
| `ErrorCard` | `error-card.tsx` | 에러 카드 |
| `ErrorMessage` | `error-message.tsx` | 에러 메시지 |
| `LoadingSpinner` | `loading-spinner.tsx` | 로딩 스피너 |
| `Skeleton` | `skeleton.tsx` | 스켈레톤 (로딩 플레이스홀더) |
| `toast` | `sonner` 라이브러리 | 토스트 알림 |
---
## 레이아웃 컴포넌트
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Card` | `card.tsx` | 카드 (CardHeader, CardContent, CardFooter) |
| `Dialog` | `dialog.tsx` | 다이얼로그 (모달) |
| `Drawer` | `drawer.tsx` | 드로어 (하단/측면 패널) |
| `Popover` | `popover.tsx` | 팝오버 |
| `Sheet` | `sheet.tsx` | 시트 (측면 패널) |
| `Accordion` | `accordion.tsx` | 아코디언 (접기/펼치기) |
| `Tabs` | `tabs.tsx` | 탭 |
| `Table` | `table.tsx` | 테이블 (Table, TableHeader, TableBody, TableRow, TableCell) |
---
## 기타 컴포넌트
| 컴포넌트 | 파일 | 용도 |
|----------|------|------|
| `Label` | `label.tsx` | 라벨 |
| `Separator` | `separator.tsx` | 구분선 |
| `ScrollArea` | `scroll-area.tsx` | 스크롤 영역 |
| `Tooltip` | `tooltip.tsx` | 툴팁 |
| `Progress` | `progress.tsx` | 진행률 바 |
| `FileList` | `file-list.tsx` | 파일 목록 표시 |
| `ChartWrapper` | `chart-wrapper.tsx` | 차트 래퍼 (Recharts) |
| `EmptyState` | `empty-state.tsx` | 빈 상태 표시 |
---
## DatePicker 사용법
프로젝트 전체에서 `<input type="date">` 대신 사용.
```tsx
import { DatePicker } from '@/components/ui/date-picker';
// 기본
<DatePicker
value={date} // "yyyy-MM-dd" 문자열
onChange={(date) => setDate(date)}
/>
// 옵션
<DatePicker
value={date}
onChange={setDate}
placeholder="날짜 선택"
size="sm" // "default" | "sm" | "lg"
disabled={!isEditMode}
minDate={new Date('2024-01-01')}
maxDate={new Date()}
/>
```
**Props**:
- `value`: `string` (yyyy-MM-dd 형식)
- `onChange`: `(date: string) => void`
- `size?`: `"default"` | `"sm"` | `"lg"`
- `disabled?`, `placeholder?`, `className?`
- `minDate?`, `maxDate?`: `Date` 타입 (**문자열 아님**)
---
## Radix UI Select 주의사항
빈 값('')으로 마운트 후 value 변경이 반영 안 되는 버그:
```tsx
// ✅ key prop으로 강제 리마운트
<Select
key={`${fieldKey}-${stringValue}`}
value={stringValue}
onValueChange={onChange}
>
{/* options */}
</Select>
```
---
## 팝업 정책
```
❌ 금지: alert(), confirm(), prompt()
✅ 사용: AlertDialog, ConfirmDialog, toast (sonner)
```
```tsx
// 토스트
import { toast } from 'sonner';
toast.success('저장되었습니다');
toast.error('오류가 발생했습니다');
// 확인 다이얼로그
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>삭제 확인</AlertDialogTitle>
<AlertDialogDescription>정말 삭제하시겠습니까?</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>취소</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>삭제</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
```

View File

@@ -0,0 +1,186 @@
# 공통 Hooks
**경로**: `src/hooks/`
---
## 리스트 페이지 관련
### useColumnSettings
테이블 컬럼 표시/숨기기 및 너비 관리. `IntegratedListTemplateV2`와 함께 사용.
```tsx
import { useColumnSettings } from '@/hooks/useColumnSettings';
const columns = [
{ key: 'name', label: '이름', width: '200px' },
{ key: 'code', label: '코드', width: '150px' },
{ key: 'status', label: '상태', width: '100px' },
];
const {
visibleColumns, // 현재 표시되는 컬럼
allColumnsWithVisibility, // 전체 컬럼 (visibility/locked 포함)
columnWidths, // 컬럼 너비 맵
setColumnWidth, // 컬럼 너비 변경
toggleColumnVisibility, // 컬럼 표시/숨기기 토글
resetSettings, // 초기화
hasHiddenColumns, // 숨겨진 컬럼 존재 여부
} = useColumnSettings({
pageId: 'item-list', // Zustand 저장 키 (고유)
columns,
alwaysVisibleKeys: ['name'], // 항상 표시되는 컬럼 (숨기기 불가)
});
```
### useListHandlers
리스트 페이지 검색, 필터, 페이지네이션 핸들러 통합.
```tsx
import { useListHandlers } from '@/hooks/useListHandlers';
const { search, setSearch, pagination, handlePageChange, handleSearch } = useListHandlers({
initialSearch: '',
fetchData: getItems,
});
```
### useCRUDHandlers
생성, 수정, 삭제 핸들러 통합.
```tsx
import { useCRUDHandlers } from '@/hooks/useCRUDHandlers';
const { handleCreate, handleUpdate, handleDelete, isSubmitting } = useCRUDHandlers({
createFn: createItem,
updateFn: updateItem,
deleteFn: deleteItems,
onSuccess: () => fetchData(),
});
```
### useDeleteDialog
삭제 확인 다이얼로그 상태 관리.
```tsx
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
const { isOpen, openDialog, closeDialog, confirmDelete, targetId } = useDeleteDialog({
onConfirm: async (id) => {
await deleteItem(id);
fetchData();
},
});
```
---
## 상세 페이지 관련
### useDetailPageState
상세/수정/등록 페이지의 모드 및 상태 관리.
```tsx
import { useDetailPageState } from '@/hooks/useDetailPageState';
const { mode, isEditMode, isNewMode, isViewMode } = useDetailPageState();
```
### useDetailData
상세 데이터 비동기 로드.
```tsx
import { useDetailData } from '@/hooks/useDetailData';
const { data, isLoading, error, refetch } = useDetailData({
id: params.id,
fetchFn: getItem,
});
```
---
## 데이터 관련
### useCommonCodes
공통 코드 조회 (상태, 분류 등).
```tsx
import { useCommonCodes } from '@/hooks/useCommonCodes';
const { codes, isLoading } = useCommonCodes('item_status');
// codes: [{ id: 'active', name: '활성' }, { id: 'inactive', name: '비활성' }]
```
### useClientList
거래처 목록 조회.
```tsx
import { useClientList } from '@/hooks/useClientList';
const { clients, isLoading } = useClientList();
```
### useItemList
품목 목록 조회.
```tsx
import { useItemList } from '@/hooks/useItemList';
const { items, isLoading } = useItemList();
```
---
## 유틸리티 관련
### useDateRange
날짜 범위 상태 관리.
```tsx
import { useDateRange } from '@/hooks/useDateRange';
const { startDate, endDate, setStartDate, setEndDate, reset } = useDateRange({
defaultStart: '2024-01-01',
defaultEnd: '2024-12-31',
});
```
### usePermission
권한 기반 접근 제어.
```tsx
import { usePermission } from '@/hooks/usePermission';
const { canRead, canWrite, canDelete } = usePermission('item_master');
if (!canWrite) {
return <div>수정 권한이 없습니다.</div>;
}
```
### useDaumPostcode
다음 우편번호 API 연동.
```tsx
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
const { openPostcode } = useDaumPostcode({
onComplete: (data) => {
setAddress(data.address);
setZipCode(data.zonecode);
},
});
```

View File

@@ -0,0 +1,175 @@
# 유틸리티 함수
---
## cn - 클래스명 병합
```typescript
import { cn } from '@/lib/utils';
// clsx + tailwind-merge
<div className={cn('p-4 bg-white', isActive && 'bg-blue-50', className)} />
```
## safeJsonParse - 안전한 JSON 파싱
```typescript
import { safeJsonParse } from '@/lib/utils';
const data = safeJsonParse<Config>(localStorage.getItem('config'), defaultConfig);
// 파싱 실패 시 fallback 반환
```
---
## 포맷팅 함수
**경로**: `src/lib/formatters.ts`
### 전화번호
```typescript
import { formatPhoneNumber, parsePhoneNumber } from '@/lib/formatters';
formatPhoneNumber('01012345678') // → "010-1234-5678"
parsePhoneNumber('010-1234-5678') // → "01012345678" (숫자만)
```
### 사업자번호
```typescript
import { formatBusinessNumber, validateBusinessNumber } from '@/lib/formatters';
formatBusinessNumber('1234567890') // → "123-45-67890"
validateBusinessNumber('1234567890') // → true/false (체크섬 검증)
```
### 주민번호
```typescript
import { formatPersonalNumber, formatPersonalNumberMasked } from '@/lib/formatters';
formatPersonalNumber('9001011234567') // → "900101-1234567"
formatPersonalNumberMasked('9001011234567') // → "900101-*******"
```
### 카드/계좌번호
```typescript
import { formatCardNumber, formatAccountNumber } from '@/lib/formatters';
formatCardNumber('1234567890123456') // → "1234-5678-9012-3456"
formatAccountNumber('12345678901234') // → "1234-5678-9012-34"
```
### 숫자/금액
```typescript
import { formatNumber, parseNumber, extractDigits } from '@/lib/formatters';
formatNumber(1234567) // → "1,234,567"
parseNumber('1,234,567') // → 1234567
extractDigits('abc-123-def') // → "123"
```
---
## URL 빌더
**경로**: `src/lib/api/query-params.ts`
```typescript
import { buildApiUrl, buildQueryParams } from '@/lib/api/query-params';
// API URL 생성 (undefined/null/'' 자동 필터링)
const url = buildApiUrl('/api/v1/items', {
search: 'test',
status: undefined, // 제외
page: 1,
});
// 쿼리 파라미터만 생성
const params = buildQueryParams({ search: 'test', page: 1 });
// → URLSearchParams 객체
```
---
## 인쇄 유틸리티
**경로**: `src/lib/print-utils.ts`
```typescript
import { printElement, printArea } from '@/lib/print-utils';
// 특정 요소 인쇄
printElement(document.getElementById('invoice'));
// .print-area 클래스 영역 인쇄
printArea({ title: '견적서' });
// 옵션
printElement('#invoice', {
title: '견적서', // 브라우저 탭 제목
styles: customCSS, // 추가 CSS
closeAfterPrint: true, // 인쇄 후 창 닫기
});
```
**HTML에서 사용**:
```tsx
<div className="print-area">
{/* 인쇄될 영역 */}
</div>
```
---
## 인증 헤더
**경로**: `src/lib/api/auth-headers.ts`
```typescript
import { getAuthHeaders, getMultipartHeaders, hasAuthToken } from '@/lib/api/auth-headers';
// JSON 요청 헤더 (프록시 사용 시)
const headers = getAuthHeaders();
// → { 'Content-Type': 'application/json', 'Accept': 'application/json' }
// Multipart FormData 헤더
const headers = getMultipartHeaders();
// → { 'Accept': 'application/json' }
// 인증 상태 확인 (클라이언트)
if (hasAuthToken()) { /* 인증됨 */ }
```
---
## 에러 처리
**경로**: `src/lib/api/errors.ts`
```typescript
import { createErrorResponse, isApiError, isAuthError } from '@/lib/api/errors';
// 에러 응답 생성
const error = createErrorResponse(404, '데이터를 찾을 수 없습니다');
// 에러 타입 확인
if (isApiError(response)) { /* API 에러 */ }
if (isAuthError(response)) { /* 인증 에러 (401) */ }
```
---
## localStorage 접근 (Next.js 호환)
```typescript
// ✅ Next.js Pattern (SSR 안전)
const [data, setData] = useState(() => {
if (typeof window === 'undefined') return defaultValue;
const saved = localStorage.getItem('key');
return saved ? JSON.parse(saved) : defaultValue;
});
```

View File

@@ -0,0 +1,205 @@
# 코딩 컨벤션 및 필수 규칙
---
## Client Component 필수
모든 페이지는 `'use client'` 선언 필수. Server Component 사용 금지.
```tsx
// ✅ 올바른 패턴
'use client';
export default function Page() { ... }
// ❌ 금지
export default async function Page() { ... }
```
**이유**: 폐쇄형 ERP (SEO 불필요), Server Component에서 쿠키 수정(토큰 갱신) 불가
## 데이터 로딩 패턴
```tsx
'use client';
import { useEffect, useState } from 'react';
import { getData } from '@/components/.../actions';
export default function Page() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
getData()
.then(result => {
if (result.success) setData(result.data);
})
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>로딩 ...</div>;
return <Component data={data} />;
}
```
---
## buildApiUrl 필수 사용
```tsx
// ✅ 필수
import { buildApiUrl } from '@/lib/api/query-params';
const url = buildApiUrl('/api/v1/items', { search, page });
// ❌ 금지
const params = new URLSearchParams();
params.set('search', value);
const url = `${API_URL}/api/v1/items?${params.toString()}`;
```
---
## 컴포넌트 재사용 우선
새 컴포넌트 작성 전 확인 순서:
1. `src/components/organisms/index.ts` export 목록
2. `src/components/molecules/` 내 공통 컴포넌트
3. `src/components/ui/` 내 UI 컴포넌트
4. dev/component-registry 페이지 검색
5. 동일 도메인 기존 컴포넌트
---
## FormField 사용 (신규 폼)
```tsx
// ✅ 신규 폼 - FormField 사용
<FormField label="회사명" value={v} onChange={handleChange} />
// ❌ 신규 폼에서 수동 조합 금지
<div className="space-y-2">
<Label>회사명</Label>
<Input value={v} onChange={handleChange} />
</div>
```
**기존 폼**: 건드리지 않음 (정상 작동 중이면 마이그레이션 불필요)
---
## Zod 스키마 검증 (신규 폼)
```tsx
import { z } from 'zod';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
// 1. 스키마 정의
const formSchema = z.object({
itemName: z.string().min(1, '품목명을 입력하세요'),
quantity: z.number().min(1, '1 이상 입력하세요'),
status: z.enum(['active', 'inactive']),
memo: z.string().optional(),
});
// 2. 타입 추출 (별도 interface 정의 불필요)
type FormData = z.infer<typeof formSchema>;
// 3. useForm에 연결
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: { itemName: '', quantity: 1, status: 'active' },
});
```
**규칙**:
- 에러 메시지 한글 작성
- 스키마 위치: 컴포넌트 파일 상단 또는 `schema.ts`
- `z.infer` 사용, 별도 `interface` 중복 정의 금지
---
## 팝업 정책
```
❌ 금지: alert(), confirm(), prompt()
✅ 사용: Radix UI Dialog/AlertDialog, toast (sonner)
```
---
## 검색 모달 표준
```
❌ 금지: Dialog + Input + 리스트 직접 조합
✅ 사용: SearchableSelectionModal<T>
```
---
## 리스트 페이지 필수 항목
`IntegratedListTemplateV2` 사용 시:
- [ ] `useColumnSettings` + `ColumnSettingsPopover` 적용
- [ ] `renderMobileCard` (모바일 카드) 구현
- [ ] `selectedItems: Set<string>` (체크박스) 구현
- [ ] `tableHeaderActions` (테이블 내 필터) 필요 시 구현
---
## 테이블 rowSpan/colSpan (문서/보고서)
**반드시 구조 분석 → 코딩 순서**:
1. **플랫 인덱스 맵**: 실제 렌더링 행 수 기준으로 인덱스 산정
2. **병합 범위 표기**: span은 그룹 첫 행에만
3. **Coverage Map 패턴**:
```typescript
function buildCoverageMap(items, spanKey) {
const map = {};
const covered = new Set();
items.forEach((item, idx) => {
const span = item[spanKey];
if (span && span > 1) {
map[idx] = span;
for (let i = idx + 1; i < idx + span; i++) covered.add(i);
}
});
return { map, covered };
}
// map에 있으면 → <td rowSpan={span}>
// covered에 있으면 → skip (렌더링 안 함)
// 둘 다 아니면 → 일반 <td>
```
---
## Git 규칙
- **develop**: 평소 작업 (자유롭게 커밋)
- **main**: 기능별 squash merge만 (직접 push 금지)
- **커밋 메시지**: `[타입]: 작업내용` (feat, fix, chore, refactor 등)
- **`snapshot.txt`, `.DS_Store`**: 항상 제외
---
## 빌드 정책
- 개발자가 직접 빌드 확인
- TypeScript strict 모드 사용
- ESLint: 빌드 시 무시 (CI에서 별도 처리)
---
## 신규 페이지 생성 체크리스트
- [ ] `'use client'` 선언
- [ ] `?mode=new/edit` 쿼리파라미터 패턴 사용 (`/new`, `/edit` 경로 금지)
- [ ] Server Action에서 `buildApiUrl()` 사용
- [ ] 기존 컴포넌트 재사용 확인 (organisms, molecules 검색)
- [ ] 리스트 페이지: `IntegratedListTemplateV2` 사용 검토
- [ ] 폼 페이지: FormField, Zod 스키마 사용 (신규)
- [ ] 검색 모달: `SearchableSelectionModal` 사용
- [ ] 하단 sticky 액션 바 구현
- [ ] 모바일 반응형 대응
- [ ] 타입 체크 (`npx tsc --noEmit`)

View File

@@ -0,0 +1,46 @@
/**
* JSON.parse 글로벌 패치 - macOS 26 파일시스템 손상 대응
*
* macOS 26에서 atomic write(tmp + rename)가 실패하면
* .next/prerender-manifest.json 등의 파일에 데이터가 중복 기록됨.
* 이로 인해 "Unexpected non-whitespace character after JSON at position N" 발생.
*
* 이 패치는 JSON.parse 실패 시 유효한 JSON 부분만 추출하여 자동 복구.
* NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' 로 로드.
*/
'use strict';
const originalParse = JSON.parse;
JSON.parse = function patchedJsonParse(text, reviver) {
try {
return originalParse.call(this, text, reviver);
} catch (e) {
if (e instanceof SyntaxError && typeof text === 'string') {
// "Unexpected non-whitespace character after JSON at position N"
// → position N까지가 유효한 JSON
const match = e.message.match(/after JSON at position\s+(\d+)/);
if (match) {
const pos = parseInt(match[1], 10);
if (pos > 0) {
try {
const result = originalParse.call(this, text.substring(0, pos), reviver);
// 한 번만 경고 (같은 position이면 반복 출력 방지)
if (!patchedJsonParse._warned) patchedJsonParse._warned = new Set();
const key = pos + ':' + text.length;
if (!patchedJsonParse._warned.has(key)) {
patchedJsonParse._warned.add(key);
console.warn(
`[patch-json-parse] macOS 파일 손상 자동 복구 (position ${pos}, total ${text.length} bytes)`
);
}
return result;
} catch {
// truncation으로도 실패하면 원래 에러 throw
}
}
}
}
throw e;
}
};

View File

@@ -0,0 +1,49 @@
/**
* .next 빌드 캐시 무결성 검증
*
* macOS 26 파일시스템 이슈로 .next/ 내 JSON 파일이 손상될 수 있음.
* (atomic write 실패 → 데이터 중복 기록)
* dev 서버 시작 전 자동 검증하여 손상 시 .next 삭제.
*/
import { readFileSync, rmSync, existsSync, readdirSync } from 'fs';
import { join } from 'path';
const NEXT_DIR = '.next';
if (!existsSync(NEXT_DIR)) {
process.exit(0);
}
const jsonFiles = [];
try {
// .next/ 루트의 JSON 파일들
for (const f of readdirSync(NEXT_DIR)) {
if (f.endsWith('.json')) jsonFiles.push(join(NEXT_DIR, f));
}
// .next/server/ 의 JSON 파일들
const serverDir = join(NEXT_DIR, 'server');
if (existsSync(serverDir)) {
for (const f of readdirSync(serverDir)) {
if (f.endsWith('.json')) jsonFiles.push(join(serverDir, f));
}
}
} catch {
// 디렉토리 읽기 실패 시 무시
}
let corrupted = false;
for (const file of jsonFiles) {
try {
const content = readFileSync(file, 'utf8');
JSON.parse(content);
} catch (e) {
console.warn(`⚠️ 손상된 캐시 발견: ${file}`);
console.warn(` ${e.message}`);
corrupted = true;
}
}
if (corrupted) {
console.warn('🗑️ .next 캐시를 삭제하고 재빌드합니다...');
rmSync(NEXT_DIR, { recursive: true, force: true });
}

View File

@@ -55,7 +55,7 @@ export default function BadDebtCollectionPage() {
return ( return (
<BadDebtCollection <BadDebtCollection
initialData={data} 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}
/> />
); );
} }

View File

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

View File

@@ -0,0 +1,5 @@
import { CompletedBox } from '@/components/approval/CompletedBox';
export default function ApprovalCompletedPage() {
return <CompletedBox />;
}

View File

@@ -103,7 +103,7 @@ function BoardListContent({ boardCode }: { boardCode: string }) {
// 게시글 목록 // 게시글 목록
const [posts, setPosts] = useState<BoardPost[]>([]); const [posts, setPosts] = useState<BoardPost[]>([]);
const [isLoading, setIsLoading] = useState(true); const [, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 필터 및 검색 // 필터 및 검색
@@ -239,11 +239,11 @@ function BoardListContent({ boardCode }: { boardCode: string }) {
// 테이블 컬럼 // 테이블 컬럼
const tableColumns: TableColumn[] = useMemo(() => [ const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' }, { key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'title', label: '제목', className: 'min-w-[200px]' }, { key: 'title', label: '제목', className: 'min-w-[200px]', copyable: true },
{ key: 'author', label: '작성자', className: 'w-[120px]' }, { key: 'author', label: '작성자', className: 'w-[120px]', copyable: true },
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' }, { key: 'views', label: '조회수', className: 'w-[80px] text-center', copyable: true },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' }, { key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' }, { key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true },
], []); ], []);
// 테이블 행 렌더링 // 테이블 행 렌더링

View File

@@ -29,7 +29,7 @@ import {
deleteDynamicBoardPost, deleteDynamicBoardPost,
} from '@/components/board/DynamicBoard/actions'; } from '@/components/board/DynamicBoard/actions';
import { getBoardByCode } from '@/components/board/BoardManagement/actions'; import { getBoardByCode } from '@/components/board/BoardManagement/actions';
import { transformApiToComment, type CommentApiData } from '@/components/customer-center/shared/types'; import { transformApiToComment } from '@/components/customer-center/shared/types';
import type { PostApiData } from '@/components/customer-center/shared/types'; import type { PostApiData } from '@/components/customer-center/shared/types';
import { sanitizeHTML } from '@/lib/sanitize'; import { sanitizeHTML } from '@/lib/sanitize';

View File

@@ -110,7 +110,7 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) {
// 게시글 목록 // 게시글 목록
const [posts, setPosts] = useState<BoardPost[]>([]); const [posts, setPosts] = useState<BoardPost[]>([]);
const [isLoading, setIsLoading] = useState(true); const [, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// 필터 및 검색 // 필터 및 검색
@@ -246,11 +246,11 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) {
// 테이블 컬럼 // 테이블 컬럼
const tableColumns: TableColumn[] = useMemo(() => [ const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' }, { key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'title', label: '제목', className: 'min-w-[200px]' }, { key: 'title', label: '제목', className: 'min-w-[200px]', copyable: true },
{ key: 'author', label: '작성자', className: 'w-[120px]' }, { key: 'author', label: '작성자', className: 'w-[120px]', copyable: true },
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' }, { key: 'views', label: '조회수', className: 'w-[80px] text-center', copyable: true },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' }, { key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' }, { key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true },
], []); ], []);
// 테이블 행 렌더링 // 테이블 행 렌더링

View File

@@ -1,3 +1,5 @@
'use client';
import { CategoryManagement } from '@/components/business/construction/category-management'; import { CategoryManagement } from '@/components/business/construction/category-management';
export default function CategoriesPage() { export default function CategoriesPage() {

View File

@@ -18,7 +18,7 @@ interface OrderDetailPageProps {
export default function OrderDetailPage({ params }: OrderDetailPageProps) { export default function OrderDetailPage({ params }: OrderDetailPageProps) {
const { id } = use(params); const { id } = use(params);
const router = useRouter(); const _router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(undefined); const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(undefined);

View File

@@ -13,7 +13,7 @@ interface ContractDetailPageProps {
export default function ContractDetailPage({ params }: ContractDetailPageProps) { export default function ContractDetailPage({ params }: ContractDetailPageProps) {
const { id } = use(params); const { id } = use(params);
const router = useRouter(); const _router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined); const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);

View File

@@ -15,7 +15,7 @@ interface HandoverReportDetailPageProps {
export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) { export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
const { id } = use(params); const { id } = use(params);
const router = useRouter(); const _router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view'; const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined); const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);

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'; import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal';
// UI - 추가 // UI - 추가
import { VisuallyHidden } from '@/components/ui/visually-hidden'; 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 // Lucide icons for demos
import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react'; 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 ── // ── Preview Registry ──
type PreviewEntry = { 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 ─── // ─── Atoms ───
'BadgeSm.tsx': [ 'BadgeSm.tsx': [
{ {
@@ -1184,6 +1283,36 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
{ label: 'Filter', render: () => <MobileFilterDemo /> }, { 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 ─── // ─── Organisms ───
'EmptyState.tsx': [ 'EmptyState.tsx': [
{ {
@@ -1440,4 +1569,8 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
'SearchableSelectionModal.tsx': [ 'SearchableSelectionModal.tsx': [
{ label: 'Modal', render: () => <SearchableSelectionDemo /> }, { label: 'Modal', render: () => <SearchableSelectionDemo /> },
], ],
'LineItemsTable.tsx': [
{ label: 'Line Items', render: () => <LineItemsTableDemo /> },
],
}; };

View File

@@ -4,7 +4,6 @@ import { useState, useCallback } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout'; import { PageLayout } from '@/components/organisms/PageLayout';
import { EditableTable, EditableColumn } from '@/components/common/EditableTable'; import { EditableTable, EditableColumn } from '@/components/common/EditableTable';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { import {

View File

@@ -75,6 +75,7 @@ export default function AttendancePage() {
setSiteLocation(finalLocation); setSiteLocation(finalLocation);
} else { } else {
// no fallback location needed
} }
} catch (error) { } catch (error) {
console.error('[AttendancePage] loadSettings error:', error); console.error('[AttendancePage] loadSettings error:', error);

View File

@@ -11,23 +11,17 @@
'use client'; 'use client';
import { useSearchParams, useRouter } from 'next/navigation'; import { useSearchParams, useRouter } from 'next/navigation';
import { useState, useEffect, useMemo, Suspense } from 'react'; import { useState, useMemo, Suspense } from 'react';
import { FileText, ArrowLeft, Calendar, User, Clock, MapPin, FileCheck } from 'lucide-react'; import { FileText, ArrowLeft, Calendar, Clock, MapPin, FileCheck } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FormSectionSkeleton } from '@/components/ui/skeleton'; import { FormSectionSkeleton } from '@/components/ui/skeleton';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ko } from 'date-fns/locale';
import { toast } from 'sonner'; import { toast } from 'sonner';
// 문서 유형 라벨 // 문서 유형 라벨

View File

@@ -4,7 +4,7 @@ import { CSVUploadPage } from '@/components/hr/EmployeeManagement/CSVUploadPage'
import type { Employee } from '@/components/hr/EmployeeManagement/types'; import type { Employee } from '@/components/hr/EmployeeManagement/types';
export default function EmployeeCSVUploadPage() { export default function EmployeeCSVUploadPage() {
const handleUpload = (employees: Employee[]) => { const handleUpload = (_employees: Employee[]) => {
// TODO: API 연동 // TODO: API 연동
}; };

View File

@@ -50,7 +50,7 @@ function EmployeeManagementContent() {
toast.error(errorMessage); toast.error(errorMessage);
return { success: false, error: errorMessage }; return { success: false, error: errorMessage };
} }
} catch (error) { } catch (_error) {
toast.error('서버 오류가 발생했습니다.'); toast.error('서버 오류가 발생했습니다.');
return { success: false, error: '서버 오류가 발생했습니다.' }; return { success: false, error: '서버 오류가 발생했습니다.' };
} }

View File

@@ -0,0 +1,12 @@
'use client';
/**
* 일상점검표 - 그리드 매트릭스
* URL: /quality/equipment-inspections
*/
import { EquipmentInspectionGrid } from '@/components/quality/EquipmentInspection';
export default function EquipmentInspectionsPage() {
return <EquipmentInspectionGrid />;
}

View File

@@ -0,0 +1,22 @@
'use client';
/**
* 레거시 리다이렉트: /quality/equipment-repairs/new → ?mode=new
*/
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function RepairNewRedirect() {
const router = useRouter();
useEffect(() => {
router.replace('/quality/equipment-repairs?mode=new');
}, [router]);
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
/**
* 수리이력 목록/등록
* URL: /quality/equipment-repairs
* URL: /quality/equipment-repairs?mode=new (등록)
*/
import { useSearchParams } from 'next/navigation';
import { RepairList } from '@/components/quality/EquipmentRepair';
import { RepairForm } from '@/components/quality/EquipmentRepair/RepairForm';
export default function EquipmentRepairsPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
if (mode === 'new') {
return <RepairForm />;
}
return <RepairList />;
}

View File

@@ -0,0 +1,12 @@
'use client';
/**
* 설비 현황 대시보드
* URL: /quality/equipment-status
*/
import { EquipmentStatusDashboard } from '@/components/quality/EquipmentStatus';
export default function EquipmentStatusPage() {
return <EquipmentStatusDashboard />;
}

View File

@@ -0,0 +1,19 @@
'use client';
/**
* 설비 상세/수정 페이지
* URL: /quality/equipment/[id]
* 수정 모드: /quality/equipment/[id]?mode=edit
*/
import { use } from 'react';
import { EquipmentDetail } from '@/components/quality/EquipmentManagement/EquipmentDetail';
interface Props {
params: Promise<{ id: string }>;
}
export default function EquipmentDetailPage({ params }: Props) {
const { id } = use(params);
return <EquipmentDetail id={id} />;
}

View File

@@ -0,0 +1,12 @@
'use client';
/**
* 설비 엑셀 Import 페이지
* URL: /quality/equipment/import
*/
import { EquipmentImport } from '@/components/quality/EquipmentManagement/EquipmentImport';
export default function EquipmentImportPage() {
return <EquipmentImport />;
}

View File

@@ -0,0 +1,22 @@
'use client';
/**
* 레거시 리다이렉트: /quality/equipment/new → ?mode=new
*/
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function EquipmentNewRedirect() {
const router = useRouter();
useEffect(() => {
router.replace('/quality/equipment?mode=new');
}, [router]);
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
/**
* 설비 등록대장 - 목록/등록
* URL: /quality/equipment
* URL: /quality/equipment?mode=new (등록)
*/
import { useSearchParams } from 'next/navigation';
import { EquipmentManagement } from '@/components/quality/EquipmentManagement';
import { EquipmentForm } from '@/components/quality/EquipmentManagement/EquipmentForm';
export default function EquipmentPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
if (mode === 'new') {
return <EquipmentForm />;
}
return <EquipmentManagement />;
}

View File

@@ -0,0 +1,340 @@
'use server';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
// ===== API 원본 타입 (snake_case) =====
// ⚠️ 'use server' 파일에서 타입 re-export 금지 (Turbopack 제한)
interface QualityReportApi {
id: number;
code: string;
site_name: string;
item: string;
route_count: number;
total_routes: number;
quarter: string;
year: number;
quarter_num: number;
}
interface RouteItemApi {
id: number;
code: string;
date: string;
client: string;
site: string;
location_count: number;
sub_items: {
id: number;
name: string;
location: string;
is_completed: boolean;
}[];
}
interface DocumentApi {
id: number;
type: string;
title: string;
date?: string;
count: number;
file_id?: number;
file_name?: string;
file_size?: number;
items?: {
id: number;
title: string;
date: string;
code?: string;
sub_type?: string;
work_order_id?: number;
}[];
}
// ===== Transform 함수 (snake_case → camelCase) =====
function transformReportApi(api: QualityReportApi) {
return {
id: String(api.id),
code: api.code,
siteName: api.site_name,
item: api.item,
routeCount: api.route_count,
totalRoutes: api.total_routes,
quarter: api.quarter,
year: api.year,
quarterNum: api.quarter_num,
};
}
function transformRouteApi(api: RouteItemApi) {
return {
id: String(api.id),
code: api.code,
date: api.date,
client: api.client,
site: api.site,
locationCount: api.location_count,
subItems: api.sub_items.map((s) => ({
id: String(s.id),
name: s.name,
location: s.location,
isCompleted: s.is_completed,
})),
};
}
function transformDocumentApi(api: DocumentApi) {
return {
id: String(api.id),
type: api.type as 'import' | 'order' | 'log' | 'report' | 'confirmation' | 'shipping' | 'product' | 'quality',
title: api.title,
date: api.date,
count: api.count,
fileId: api.file_id,
fileName: api.file_name,
fileSize: api.file_size,
items: api.items?.map((i) => ({
id: String(i.id),
title: i.title,
date: i.date,
code: i.code,
subType: i.sub_type as 'screen' | 'bending' | 'slat' | 'jointbar' | undefined,
workOrderId: i.work_order_id,
})),
};
}
// ===== 2일차: 로트 추적 심사 =====
export async function getQualityReports(params: {
year: number;
quarter?: number;
q?: string;
}) {
return executeServerAction({
url: buildApiUrl('/api/v1/qms/lot-audit/reports', {
year: params.year,
quarter: params.quarter,
q: params.q,
}),
transform: (data: { items: QualityReportApi[] }) =>
data.items.map(transformReportApi),
errorMessage: '품질관리서 목록 조회에 실패했습니다.',
});
}
export async function getReportRoutes(reportId: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/lot-audit/reports/${reportId}`),
transform: (data: RouteItemApi[]) => data.map(transformRouteApi),
errorMessage: '수주/개소 목록 조회에 실패했습니다.',
});
}
export async function getRouteDocuments(routeId: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/lot-audit/routes/${routeId}/documents`),
transform: (data: DocumentApi[]) => data.map(transformDocumentApi),
errorMessage: '서류 목록 조회에 실패했습니다.',
});
}
export async function getDocumentDetail(type: string, id: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/lot-audit/documents/${type}/${id}`),
errorMessage: '서류 상세 조회에 실패했습니다.',
});
}
export async function confirmUnitInspection(unitId: string, confirmed: boolean) {
return executeServerAction({
url: buildApiUrl(`/api/v1/qms/lot-audit/units/${unitId}/confirm`),
method: 'PATCH',
body: { confirmed },
transform: (data: { id: number; name: string; location: string; is_completed: boolean }) => ({
id: String(data.id),
name: data.name,
location: data.location,
isCompleted: data.is_completed,
}),
errorMessage: '확인 상태 변경에 실패했습니다.',
});
}
// ===== 1일차: 점검표 항목 토글 =====
export async function toggleTemplateItem(templateId: number, subItemId: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/quality/checklist-templates/${templateId}/items/${subItemId}/toggle`),
method: 'PATCH',
transform: (data: { id: string; name: string; is_completed: boolean; completed_at?: string }) => ({
id: data.id,
name: data.name,
isCompleted: data.is_completed,
}),
errorMessage: '항목 상태 변경에 실패했습니다.',
});
}
// ===== 점검표 템플릿 관리 (설정 모달) =====
interface ChecklistTemplateApi {
id: number;
name: string;
type: string;
categories: {
id: string;
title: string;
subItems: { id: string; name: string; is_completed?: boolean }[];
}[];
options: Record<string, unknown> | null;
file_counts: Record<string, number>;
updated_at: string | null;
updated_by: string | null;
}
interface TemplateDocumentApi {
id: number;
field_key: string;
display_name: string;
file_size: number;
mime_type: string;
uploaded_by: string | null;
created_at: string | null;
}
export async function getChecklistTemplate(type: string = 'day1_audit') {
return executeServerAction({
url: buildApiUrl('/api/v1/quality/checklist-templates', { type }),
transform: (data: ChecklistTemplateApi) => ({
id: data.id,
name: data.name,
type: data.type,
categories: data.categories.map((cat) => ({
id: cat.id,
title: cat.title,
subItems: cat.subItems.map((item) => ({
id: item.id,
name: item.name,
isCompleted: item.is_completed ?? false,
})),
})),
options: data.options,
fileCounts: data.file_counts,
updatedAt: data.updated_at,
updatedBy: data.updated_by,
}),
errorMessage: '점검표 템플릿 조회에 실패했습니다.',
});
}
export async function saveChecklistTemplate(
id: number,
data: { name?: string; categories: { id: string; title: string; subItems: { id: string; name: string }[] }[]; options?: Record<string, unknown> },
) {
return executeServerAction({
url: buildApiUrl(`/api/v1/quality/checklist-templates/${id}`),
method: 'PUT',
body: data,
transform: (result: ChecklistTemplateApi) => ({
id: result.id,
name: result.name,
type: result.type,
categories: result.categories.map((cat) => ({
id: cat.id,
title: cat.title,
subItems: cat.subItems.map((item) => ({
id: item.id,
name: item.name,
isCompleted: item.is_completed ?? false,
})),
})),
options: result.options,
fileCounts: result.file_counts,
updatedAt: result.updated_at,
updatedBy: result.updated_by,
}),
errorMessage: '점검표 템플릿 저장에 실패했습니다.',
});
}
export async function getTemplateDocuments(templateId: number, subItemId?: string) {
return executeServerAction({
url: buildApiUrl('/api/v1/quality/qms-documents', {
template_id: templateId,
sub_item_id: subItemId,
}),
transform: (data: TemplateDocumentApi[]) =>
data.map((d) => ({
id: d.id,
fieldKey: d.field_key,
displayName: d.display_name,
fileSize: d.file_size,
mimeType: d.mime_type,
uploadedBy: d.uploaded_by,
createdAt: d.created_at,
})),
errorMessage: '템플릿 문서 조회에 실패했습니다.',
});
}
export async function uploadTemplateDocument(templateId: number, subItemId: string, file: File) {
const formData = new FormData();
formData.append('template_id', String(templateId));
formData.append('sub_item_id', subItemId);
formData.append('file', file);
return executeServerAction({
url: buildApiUrl('/api/v1/quality/qms-documents'),
method: 'POST',
body: formData,
transform: (d: TemplateDocumentApi) => ({
id: d.id,
fieldKey: d.field_key,
displayName: d.display_name,
fileSize: d.file_size,
mimeType: d.mime_type,
createdAt: d.created_at,
}),
errorMessage: '파일 업로드에 실패했습니다.',
});
}
export async function deleteTemplateDocument(fileId: number, replace: boolean = false) {
return executeServerAction({
url: buildApiUrl(`/api/v1/quality/qms-documents/${fileId}`, {
replace: replace ? 'true' : undefined,
}),
method: 'DELETE',
errorMessage: '파일 삭제에 실패했습니다.',
});
}
// ===== 품질관리서 파일 업로드/삭제 =====
export async function uploadQualityDocumentFile(qualityDocumentId: string, file: File) {
const formData = new FormData();
formData.append('file', file);
return executeServerAction({
url: buildApiUrl(`/api/v1/quality/documents/${qualityDocumentId}/upload-file`),
method: 'POST',
body: formData,
transform: (data: { id: number; display_name: string; file_size: number }) => ({
fileId: data.id,
fileName: data.display_name,
fileSize: data.file_size,
}),
errorMessage: '품질관리서 파일 업로드에 실패했습니다.',
});
}
export async function deleteQualityDocumentFile(qualityDocumentId: string) {
return executeServerAction({
url: buildApiUrl(`/api/v1/quality/documents/${qualityDocumentId}/file`),
method: 'DELETE',
errorMessage: '품질관리서 파일 삭제에 실패했습니다.',
});
}

View File

@@ -1,9 +1,11 @@
'use client'; 'use client';
import React from 'react'; import React, { useState } from 'react';
import { Settings, X, Eye, EyeOff } from 'lucide-react'; import { Settings, X, Eye, EyeOff, ListChecks } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { ChecklistTemplateEditor } from './ChecklistTemplateEditor';
import type { ChecklistCategory } from '../types';
export interface AuditDisplaySettings { export interface AuditDisplaySettings {
showProgressBar: boolean; showProgressBar: boolean;
@@ -13,19 +15,46 @@ export interface AuditDisplaySettings {
expandAllCategories: boolean; expandAllCategories: boolean;
} }
// 점검표 관리 props
export interface ChecklistManagementProps {
categories: ChecklistCategory[];
hasChanges: boolean;
saving: boolean;
loading?: boolean;
error?: string | null;
onAddCategory: () => void;
onUpdateCategoryTitle: (categoryId: string, title: string) => void;
onDeleteCategory: (categoryId: string) => void;
onMoveCategoryUp: (index: number) => void;
onMoveCategoryDown: (index: number) => void;
onAddSubItem: (categoryId: string) => void;
onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void;
onDeleteSubItem: (categoryId: string, subItemId: string) => void;
onMoveSubItemUp: (categoryId: string, index: number) => void;
onMoveSubItemDown: (categoryId: string, index: number) => void;
onSave: () => void;
onReset: () => void;
}
interface AuditSettingsPanelProps { interface AuditSettingsPanelProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
settings: AuditDisplaySettings; settings: AuditDisplaySettings;
onSettingsChange: (settings: AuditDisplaySettings) => void; onSettingsChange: (settings: AuditDisplaySettings) => void;
checklistManagement?: ChecklistManagementProps;
} }
type TabType = 'display' | 'checklist';
export function AuditSettingsPanel({ export function AuditSettingsPanel({
isOpen, isOpen,
onClose, onClose,
settings, settings,
onSettingsChange, onSettingsChange,
checklistManagement,
}: AuditSettingsPanelProps) { }: AuditSettingsPanelProps) {
const [activeTab, setActiveTab] = useState<TabType>('display');
const handleToggle = (key: keyof AuditDisplaySettings) => { const handleToggle = (key: keyof AuditDisplaySettings) => {
onSettingsChange({ onSettingsChange({
...settings, ...settings,
@@ -49,7 +78,7 @@ export function AuditSettingsPanel({
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50"> <div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-gray-600" /> <Settings className="h-5 w-5 text-gray-600" />
<h3 className="font-semibold text-gray-900"> </h3> <h3 className="font-semibold text-gray-900"></h3>
</div> </div>
<button <button
type="button" type="button"
@@ -60,103 +89,183 @@ export function AuditSettingsPanel({
</button> </button>
</div> </div>
{/* 설정 항목 */} {/* */}
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex border-b border-gray-200">
{/* 레이아웃 섹션 */} <button
<div> type="button"
<h4 className="text-sm font-medium text-gray-700 mb-3"></h4> onClick={() => setActiveTab('display')}
<div className="space-y-3"> className={cn(
<SettingRow 'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
label="진행률 표시" activeTab === 'display'
description="상단 전체 심사 진행률 바를 표시합니다" ? 'text-blue-600 border-b-2 border-blue-600'
checked={settings.showProgressBar} : 'text-gray-500 hover:text-gray-700'
onChange={() => handleToggle('showProgressBar')} )}
/> >
<SettingRow <Eye className="h-3.5 w-3.5" />
label="문서 뷰어"
description="우측 문서 미리보기 패널을 표시합니다" </button>
checked={settings.showDocumentViewer} <button
onChange={() => handleToggle('showDocumentViewer')} type="button"
/> onClick={() => setActiveTab('checklist')}
<SettingRow className={cn(
label="기준 문서화 섹션" 'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
description="중앙 기준 문서 목록 패널을 표시합니다" activeTab === 'checklist'
checked={settings.showDocumentSection} ? 'text-blue-600 border-b-2 border-blue-600'
onChange={() => handleToggle('showDocumentSection')} : 'text-gray-500 hover:text-gray-700'
/> )}
</div> >
</div> <ListChecks className="h-3.5 w-3.5" />
{/* 구분선 */} </button>
<div className="border-t border-gray-200" />
{/* 점검표 섹션 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="space-y-3">
<SettingRow
label="완료된 항목 표시"
description="완료된 점검 항목을 목록에 표시합니다"
checked={settings.showCompletedItems}
onChange={() => handleToggle('showCompletedItems')}
/>
<SettingRow
label="모든 카테고리 펼치기"
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
checked={settings.expandAllCategories}
onChange={() => handleToggle('expandAllCategories')}
/>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-gray-200" />
{/* 빠른 설정 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: true,
showDocumentViewer: true,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: true,
})}
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
>
</button>
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: false,
showDocumentViewer: false,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: false,
})}
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
</div>
</div>
</div> </div>
{/* 하단 안내 */} {/* 탭 컨텐츠 */}
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50"> <div className="flex-1 overflow-y-auto p-4">
<p className="text-xs text-gray-500"> {activeTab === 'display' ? (
<DisplaySettingsContent
</p> settings={settings}
onToggle={handleToggle}
onSettingsChange={onSettingsChange}
/>
) : checklistManagement ? (
<ChecklistTemplateEditor
categories={checklistManagement.categories}
hasChanges={checklistManagement.hasChanges}
saving={checklistManagement.saving}
loading={checklistManagement.loading}
error={checklistManagement.error}
onAddCategory={checklistManagement.onAddCategory}
onUpdateCategoryTitle={checklistManagement.onUpdateCategoryTitle}
onDeleteCategory={checklistManagement.onDeleteCategory}
onMoveCategoryUp={checklistManagement.onMoveCategoryUp}
onMoveCategoryDown={checklistManagement.onMoveCategoryDown}
onAddSubItem={checklistManagement.onAddSubItem}
onUpdateSubItemName={checklistManagement.onUpdateSubItemName}
onDeleteSubItem={checklistManagement.onDeleteSubItem}
onMoveSubItemUp={checklistManagement.onMoveSubItemUp}
onMoveSubItemDown={checklistManagement.onMoveSubItemDown}
onSave={checklistManagement.onSave}
onReset={checklistManagement.onReset}
/>
) : (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
...
</div>
)}
</div>
{/* 하단 안내 (화면 설정 탭일 때만) */}
{activeTab === 'display' && (
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
<p className="text-xs text-gray-500">
</p>
</div>
)}
</div>
</div>
);
}
// ===== 화면 설정 탭 컨텐츠 (기존 코드 분리) =====
interface DisplaySettingsContentProps {
settings: AuditDisplaySettings;
onToggle: (key: keyof AuditDisplaySettings) => void;
onSettingsChange: (settings: AuditDisplaySettings) => void;
}
function DisplaySettingsContent({ settings, onToggle, onSettingsChange }: DisplaySettingsContentProps) {
return (
<div className="space-y-4">
{/* 레이아웃 섹션 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"></h4>
<div className="space-y-3">
<SettingRow
label="진행률 표시"
description="상단 전체 심사 진행률 바를 표시합니다"
checked={settings.showProgressBar}
onChange={() => onToggle('showProgressBar')}
/>
<SettingRow
label="문서 뷰어"
description="우측 문서 미리보기 패널을 표시합니다"
checked={settings.showDocumentViewer}
onChange={() => onToggle('showDocumentViewer')}
/>
<SettingRow
label="기준 문서화 섹션"
description="중앙 기준 문서 목록 패널을 표시합니다"
checked={settings.showDocumentSection}
onChange={() => onToggle('showDocumentSection')}
/>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-gray-200" />
{/* 점검표 섹션 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="space-y-3">
<SettingRow
label="완료된 항목 표시"
description="완료된 점검 항목을 목록에 표시합니다"
checked={settings.showCompletedItems}
onChange={() => onToggle('showCompletedItems')}
/>
<SettingRow
label="모든 카테고리 펼치기"
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
checked={settings.expandAllCategories}
onChange={() => onToggle('expandAllCategories')}
/>
</div>
</div>
{/* 구분선 */}
<div className="border-t border-gray-200" />
{/* 빠른 설정 */}
<div>
<h4 className="text-sm font-medium text-gray-700 mb-3"> </h4>
<div className="grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: true,
showDocumentViewer: true,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: true,
})}
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
>
</button>
<button
type="button"
onClick={() => onSettingsChange({
showProgressBar: false,
showDocumentViewer: false,
showDocumentSection: true,
showCompletedItems: true,
expandAllCategories: false,
})}
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
>
</button>
</div> </div>
</div> </div>
</div> </div>
); );
} }
// ===== 공통 설정 행 =====
interface SettingRowProps { interface SettingRowProps {
label: string; label: string;
description: string; description: string;
@@ -203,4 +312,4 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
<span> </span> <span> </span>
</button> </button>
); );
} }

View File

@@ -0,0 +1,449 @@
'use client';
import React, { useState } from 'react';
import {
ChevronUp,
ChevronDown,
Pencil,
Trash2,
Plus,
Check,
X,
ChevronRight,
Save,
RotateCcw,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { ChecklistCategory, ChecklistSubItem } from '../types';
interface ChecklistTemplateEditorProps {
categories: ChecklistCategory[];
hasChanges: boolean;
saving: boolean;
loading?: boolean;
error?: string | null;
// 카테고리
onAddCategory: () => void;
onUpdateCategoryTitle: (categoryId: string, title: string) => void;
onDeleteCategory: (categoryId: string) => void;
onMoveCategoryUp: (index: number) => void;
onMoveCategoryDown: (index: number) => void;
// 하위 항목
onAddSubItem: (categoryId: string) => void;
onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void;
onDeleteSubItem: (categoryId: string, subItemId: string) => void;
onMoveSubItemUp: (categoryId: string, index: number) => void;
onMoveSubItemDown: (categoryId: string, index: number) => void;
// 저장/초기화
onSave: () => void;
onReset: () => void;
}
export function ChecklistTemplateEditor({
categories,
hasChanges,
saving,
loading,
error,
onAddCategory,
onUpdateCategoryTitle,
onDeleteCategory,
onMoveCategoryUp,
onMoveCategoryDown,
onAddSubItem,
onUpdateSubItemName,
onDeleteSubItem,
onMoveSubItemUp,
onMoveSubItemDown,
onSave,
onReset,
}: ChecklistTemplateEditorProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(categories.map(c => c.id))
);
const toggleExpand = (categoryId: string) => {
setExpandedCategories(prev => {
const next = new Set(prev);
if (next.has(categoryId)) next.delete(categoryId);
else next.add(categoryId);
return next;
});
};
if (loading) {
return (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-32 text-red-500 text-sm">
{error}
</div>
);
}
return (
<div className="space-y-3">
{/* 카테고리 목록 */}
<div className="space-y-1.5">
{categories.map((category, catIdx) => (
<CategoryEditor
key={category.id}
category={category}
index={catIdx}
isFirst={catIdx === 0}
isLast={catIdx === categories.length - 1}
isExpanded={expandedCategories.has(category.id)}
onToggleExpand={() => toggleExpand(category.id)}
onUpdateTitle={(title) => onUpdateCategoryTitle(category.id, title)}
onDelete={() => onDeleteCategory(category.id)}
onMoveUp={() => onMoveCategoryUp(catIdx)}
onMoveDown={() => onMoveCategoryDown(catIdx)}
onAddSubItem={() => onAddSubItem(category.id)}
onUpdateSubItemName={(subItemId, name) => onUpdateSubItemName(category.id, subItemId, name)}
onDeleteSubItem={(subItemId) => onDeleteSubItem(category.id, subItemId)}
onMoveSubItemUp={(idx) => onMoveSubItemUp(category.id, idx)}
onMoveSubItemDown={(idx) => onMoveSubItemDown(category.id, idx)}
/>
))}
</div>
{/* 카테고리 추가 */}
<button
type="button"
onClick={onAddCategory}
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs text-blue-600 bg-blue-50 rounded-lg border border-dashed border-blue-200 hover:bg-blue-100 transition-colors"
>
<Plus className="h-3.5 w-3.5" />
</button>
{/* 저장/초기화 */}
<div className="flex items-center gap-2 pt-2 border-t border-gray-200">
<button
type="button"
onClick={onReset}
disabled={!hasChanges}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => onSave()}
disabled={!hasChanges || saving}
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Save className="h-3.5 w-3.5" />
{saving ? '저장 중...' : '저장'}
</button>
</div>
{hasChanges && (
<p className="text-[10px] text-amber-600 text-center">
</p>
)}
</div>
);
}
// ===== 카테고리 편집 =====
interface CategoryEditorProps {
category: ChecklistCategory;
index: number;
isFirst: boolean;
isLast: boolean;
isExpanded: boolean;
onToggleExpand: () => void;
onUpdateTitle: (title: string) => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
onAddSubItem: () => void;
onUpdateSubItemName: (subItemId: string, name: string) => void;
onDeleteSubItem: (subItemId: string) => void;
onMoveSubItemUp: (index: number) => void;
onMoveSubItemDown: (index: number) => void;
}
function CategoryEditor({
category,
index,
isFirst,
isLast,
isExpanded,
onToggleExpand,
onUpdateTitle,
onDelete,
onMoveUp,
onMoveDown,
onAddSubItem,
onUpdateSubItemName,
onDeleteSubItem,
onMoveSubItemUp,
onMoveSubItemDown,
}: CategoryEditorProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(category.title);
const handleSaveTitle = () => {
const trimmed = editValue.trim();
if (trimmed) {
onUpdateTitle(trimmed);
} else {
setEditValue(category.title);
}
setEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSaveTitle();
if (e.key === 'Escape') {
setEditValue(category.title);
setEditing(false);
}
};
return (
<div className="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
{/* 카테고리 헤더 */}
<div className="flex items-center gap-1 px-2 py-1.5">
{/* 순서 변경 */}
<div className="flex flex-col">
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
{/* 펼치기/접기 */}
<button
type="button"
onClick={onToggleExpand}
className="p-0.5 text-gray-500"
>
<ChevronRight className={cn(
'h-3.5 w-3.5 transition-transform',
isExpanded && 'rotate-90'
)} />
</button>
{/* 제목 */}
{editing ? (
<div className="flex-1 flex items-center gap-1">
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSaveTitle}
onKeyDown={handleKeyDown}
className="flex-1 text-xs px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400"
autoFocus
/>
<button type="button" onClick={handleSaveTitle} className="p-0.5 text-green-600">
<Check className="h-3 w-3" />
</button>
<button type="button" onClick={() => { setEditValue(category.title); setEditing(false); }} className="p-0.5 text-gray-400">
<X className="h-3 w-3" />
</button>
</div>
) : (
<span className="flex-1 text-xs font-medium text-gray-800 truncate">
{index + 1}. {category.title}
</span>
)}
{/* 항목 수 */}
<span className="text-[10px] text-gray-400 mr-1">
{category.subItems.length}
</span>
{/* 편집/삭제 */}
{!editing && (
<>
<button
type="button"
onClick={() => { setEditValue(category.title); setEditing(true); }}
className="p-1 text-gray-400 hover:text-blue-600 rounded"
>
<Pencil className="h-3 w-3" />
</button>
<button
type="button"
onClick={onDelete}
className="p-1 text-gray-400 hover:text-red-600 rounded"
>
<Trash2 className="h-3 w-3" />
</button>
</>
)}
</div>
{/* 하위 항목 */}
{isExpanded && (
<div className="border-t border-gray-200 bg-white">
{category.subItems.map((subItem, subIdx) => (
<SubItemEditor
key={subItem.id}
subItem={subItem}
index={subIdx}
isFirst={subIdx === 0}
isLast={subIdx === category.subItems.length - 1}
onUpdateName={(name) => onUpdateSubItemName(subItem.id, name)}
onDelete={() => onDeleteSubItem(subItem.id)}
onMoveUp={() => onMoveSubItemUp(subIdx)}
onMoveDown={() => onMoveSubItemDown(subIdx)}
/>
))}
{/* 항목 추가 */}
<button
type="button"
onClick={onAddSubItem}
className="w-full flex items-center justify-center gap-1 py-1.5 text-[10px] text-blue-500 hover:bg-blue-50 transition-colors"
>
<Plus className="h-3 w-3" />
</button>
</div>
)}
</div>
);
}
// ===== 하위 항목 편집 =====
interface SubItemEditorProps {
subItem: ChecklistSubItem;
index: number;
isFirst: boolean;
isLast: boolean;
onUpdateName: (name: string) => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
function SubItemEditor({
subItem,
index,
isFirst,
isLast,
onUpdateName,
onDelete,
onMoveUp,
onMoveDown,
}: SubItemEditorProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(subItem.name);
const handleSave = () => {
const trimmed = editValue.trim();
if (trimmed) {
onUpdateName(trimmed);
} else {
setEditValue(subItem.name);
}
setEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') {
setEditValue(subItem.name);
setEditing(false);
}
};
return (
<div className="flex items-center gap-1 px-2 py-1 border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
{/* 순서 변경 */}
<div className="flex flex-col ml-4">
<button
type="button"
onClick={onMoveUp}
disabled={isFirst}
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronUp className="h-2.5 w-2.5" />
</button>
<button
type="button"
onClick={onMoveDown}
disabled={isLast}
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
>
<ChevronDown className="h-2.5 w-2.5" />
</button>
</div>
{/* 이름 */}
{editing ? (
<div className="flex-1 flex items-center gap-1">
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onBlur={handleSave}
onKeyDown={handleKeyDown}
className="flex-1 text-[11px] px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400"
autoFocus
/>
<button type="button" onClick={handleSave} className="p-0.5 text-green-600">
<Check className="h-2.5 w-2.5" />
</button>
</div>
) : (
<span
className="flex-1 text-[11px] text-gray-700 cursor-pointer hover:text-blue-600 truncate"
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
>
{subItem.name}
</span>
)}
{/* 편집/삭제 */}
{!editing && (
<>
<button
type="button"
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
className="p-0.5 text-gray-300 hover:text-blue-500"
>
<Pencil className="h-2.5 w-2.5" />
</button>
<button
type="button"
onClick={onDelete}
className="p-0.5 text-gray-300 hover:text-red-500"
>
<Trash2 className="h-2.5 w-2.5" />
</button>
</>
)}
</div>
);
}

View File

@@ -11,6 +11,7 @@ interface Day1ChecklistPanelProps {
searchTerm: string; searchTerm: string;
onSubItemSelect: (categoryId: string, subItemId: string) => void; onSubItemSelect: (categoryId: string, subItemId: string) => void;
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void; onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
isMock?: boolean;
} }
export function Day1ChecklistPanel({ export function Day1ChecklistPanel({
@@ -19,6 +20,7 @@ export function Day1ChecklistPanel({
searchTerm, searchTerm,
onSubItemSelect, onSubItemSelect,
onSubItemToggle, onSubItemToggle,
isMock,
}: Day1ChecklistPanelProps) { }: Day1ChecklistPanelProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>( const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침 new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
@@ -48,6 +50,13 @@ export function Day1ChecklistPanel({
}).filter((cat): cat is ChecklistCategory => cat !== null); }).filter((cat): cat is ChecklistCategory => cat !== null);
}, [categories, searchTerm]); }, [categories, searchTerm]);
// categories 로드 완료 시 모두 펼치기
React.useEffect(() => {
if (categories.length > 0) {
setExpandedCategories(new Set(categories.map(c => c.id)));
}
}, [categories]);
// 검색 시 모든 카테고리 펼치기 // 검색 시 모든 카테고리 펼치기
React.useEffect(() => { React.useEffect(() => {
if (searchTerm.trim()) { if (searchTerm.trim()) {
@@ -95,7 +104,14 @@ export function Day1ChecklistPanel({
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col"> <div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 */} {/* 헤더 */}
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200"> <div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3> <div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</div>
{/* 검색 결과 카운트 */} {/* 검색 결과 카운트 */}
{searchTerm && ( {searchTerm && (
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500"> <div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
@@ -114,7 +130,7 @@ export function Day1ChecklistPanel({
</div> </div>
) : ( ) : (
filteredCategories.map((category, categoryIndex) => { filteredCategories.map((category, _categoryIndex) => {
const isExpanded = expandedCategories.has(category.id); const isExpanded = expandedCategories.has(category.id);
const progress = getCategoryProgress(category); const progress = getCategoryProgress(category);
const allCompleted = progress.completed === progress.total; const allCompleted = progress.completed === progress.total;

View File

@@ -1,25 +1,45 @@
'use client'; 'use client';
import React from 'react'; import React, { useState, useRef, useCallback } from 'react';
import { FileText, Download, Eye, CheckCircle2 } from 'lucide-react'; import { FileText, CheckCircle2, Upload, X, Loader2, Trash2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import type { Day1CheckItem, StandardDocument } from '../types'; import { toast } from 'sonner';
import type { Day1CheckItem, TemplateDocument } from '../types';
const ACCEPTED_EXTENSIONS = '.pdf,.xlsx,.xls,.doc,.docx,.hwp';
const ACCEPTED_MIME = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-excel',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/haansofthwp',
];
const MAX_FILE_SIZE_MB = 20;
interface Day1DocumentSectionProps { interface Day1DocumentSectionProps {
checkItem: Day1CheckItem | null; checkItem: Day1CheckItem | null;
selectedDocumentId: string | null;
onDocumentSelect: (documentId: string) => void;
onConfirmComplete: () => void; onConfirmComplete: () => void;
isCompleted: boolean; isCompleted: boolean;
isMock?: boolean;
onFileUpload?: (subItemId: string, file: File) => Promise<boolean>;
uploadedFiles?: TemplateDocument[];
onFileDelete?: (fileId: number) => void;
onFileSelect?: (file: TemplateDocument) => void;
selectedFileId?: number | null;
} }
export function Day1DocumentSection({ export function Day1DocumentSection({
checkItem, checkItem,
selectedDocumentId,
onDocumentSelect,
onConfirmComplete, onConfirmComplete,
isCompleted, isCompleted,
isMock,
onFileUpload,
uploadedFiles = [],
onFileDelete,
onFileSelect,
selectedFileId,
}: Day1DocumentSectionProps) { }: Day1DocumentSectionProps) {
if (!checkItem) { if (!checkItem) {
return ( return (
@@ -36,7 +56,14 @@ export function Day1DocumentSection({
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col"> <div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 */} {/* 헤더 */}
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200"> <div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3> <div className="flex items-center gap-2">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</div>
</div> </div>
{/* 콘텐츠 */} {/* 콘텐츠 */}
@@ -47,19 +74,23 @@ export function Day1DocumentSection({
<p className="text-xs sm:text-sm text-blue-700">{checkItem.description}</p> <p className="text-xs sm:text-sm text-blue-700">{checkItem.description}</p>
</div> </div>
{/* 기준 문서 목록 */} {/* 관련 기준 문서 */}
<div> <div>
<h5 className="text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2"> </h5> <h5 className="text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2"> </h5>
<div className="space-y-1.5 sm:space-y-2"> <div className="space-y-1">
{checkItem.standardDocuments.map((doc) => ( {uploadedFiles.map((file) => (
<DocumentRow <UploadedFileRow
key={doc.id} key={file.id}
document={doc} file={file}
isSelected={selectedDocumentId === doc.id} isSelected={selectedFileId === file.id}
onSelect={() => onDocumentSelect(doc.id)} onSelect={onFileSelect ? () => onFileSelect(file) : undefined}
onDelete={onFileDelete ? () => onFileDelete(file.id) : undefined}
/> />
))} ))}
</div> </div>
<DocumentUploadArea
onUpload={onFileUpload ? (file) => onFileUpload(checkItem.subItemId, file) : undefined}
/>
</div> </div>
{/* 확인 버튼 */} {/* 확인 버튼 */}
@@ -91,67 +122,200 @@ export function Day1DocumentSection({
); );
} }
interface DocumentRowProps { // ===== 파일 업로드 영역 =====
document: StandardDocument;
isSelected: boolean; interface DocumentUploadAreaProps {
onSelect: () => void; onUpload?: (file: File) => Promise<boolean>;
} }
function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) { function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) {
const [isDragging, setIsDragging] = useState(false);
const [uploading, setUploading] = useState(false);
const [pendingFile, setPendingFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const validateFile = useCallback((file: File): string | null => {
const sizeMB = file.size / (1024 * 1024);
if (sizeMB > MAX_FILE_SIZE_MB) {
return `파일 크기는 ${MAX_FILE_SIZE_MB}MB 이하여야 합니다.`;
}
// 확장자 체크
const ext = file.name.split('.').pop()?.toLowerCase();
const allowed = ['pdf', 'xlsx', 'xls', 'doc', 'docx', 'hwp'];
if (!ext || !allowed.includes(ext)) {
return 'PDF, Excel, Word, HWP 파일만 업로드 가능합니다.';
}
return null;
}, []);
const handleFile = useCallback((file: File) => {
const error = validateFile(file);
if (error) {
toast.error(error);
return;
}
setPendingFile(file);
}, [validateFile]);
const handleConfirmUpload = useCallback(async () => {
if (!pendingFile || !onUpload) return;
setUploading(true);
try {
const success = await onUpload(pendingFile);
if (success) {
toast.success(`${pendingFile.name} 업로드 완료`);
setPendingFile(null);
}
} finally {
setUploading(false);
}
}, [pendingFile, onUpload]);
const handleCancelUpload = useCallback(() => {
setPendingFile(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}, []);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const file = e.dataTransfer.files?.[0];
if (file) handleFile(file);
};
// 선택된 파일 미리보기
if (pendingFile) {
const ext = pendingFile.name.split('.').pop()?.toLowerCase();
const sizeMB = (pendingFile.size / (1024 * 1024)).toFixed(1);
return (
<div className="mt-2 p-2.5 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-2">
<div className={cn(
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center',
ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
)}>
<FileText className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{pendingFile.name}</p>
<p className="text-[10px] text-gray-500">{sizeMB} MB</p>
</div>
<button
type="button"
onClick={handleCancelUpload}
className="p-1 text-gray-400 hover:text-gray-600"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={handleCancelUpload}
className="flex-1 py-1.5 text-xs text-gray-600 bg-white border border-gray-200 rounded-md hover:bg-gray-50"
>
</button>
<button
type="button"
onClick={handleConfirmUpload}
disabled={uploading}
className="flex-1 py-1.5 text-xs text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-1"
>
{uploading ? (
<>
<Loader2 className="h-3 w-3 animate-spin" />
...
</>
) : (
'업로드'
)}
</button>
</div>
</div>
);
}
return (
<>
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTENSIONS}
onChange={handleInputChange}
className="hidden"
/>
<div
onDragEnter={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }}
onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
'mt-2 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border-2 border-dashed cursor-pointer transition-colors',
isDragging
? 'border-blue-400 bg-blue-50'
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
)}
>
<Upload className="h-3.5 w-3.5 text-gray-400" />
<span className="text-xs text-gray-500">
(PDF, Excel, Word, HWP)
</span>
</div>
</>
);
}
// ===== 업로드된 파일 행 =====
interface UploadedFileRowProps {
file: TemplateDocument;
isSelected?: boolean;
onSelect?: () => void;
onDelete?: () => void;
}
function UploadedFileRow({ file, isSelected, onSelect, onDelete }: UploadedFileRowProps) {
const ext = file.displayName.split('.').pop()?.toLowerCase();
const sizeMB = (file.fileSize / (1024 * 1024)).toFixed(1);
return ( return (
<div <div
className={cn( className={cn(
'flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border cursor-pointer transition-colors', 'flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-colors',
isSelected isSelected
? 'bg-blue-50 border-blue-300' ? 'bg-blue-50 border-blue-300'
: 'bg-gray-50 border-gray-200 hover:bg-gray-100' : 'bg-gray-50 border-gray-200 hover:bg-gray-100'
)} )}
onClick={onSelect} onClick={onSelect}
> >
{/* 아이콘 */}
<div className={cn( <div className={cn(
'flex-shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center', 'flex-shrink-0 w-7 h-7 rounded flex items-center justify-center',
document.fileName?.endsWith('.pdf') ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
? 'bg-red-100 text-red-600'
: 'bg-green-100 text-green-600'
)}> )}>
<FileText className="h-4 w-4 sm:h-5 sm:w-5" /> <FileText className="h-3.5 w-3.5" />
</div> </div>
{/* 문서 정보 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">{document.title}</p> <p className="text-xs font-medium text-gray-800 truncate">{file.displayName}</p>
<p className="text-xs text-gray-500"> <p className="text-[10px] text-gray-400">{sizeMB} MB</p>
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
<span>{document.date}</span>
</p>
</div> </div>
{onDelete && (
{/* 액션 버튼 */}
<div className="flex items-center gap-1">
<button <button
type="button" type="button"
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors" onClick={(e) => { e.stopPropagation(); onDelete(); }}
title="미리보기" className="p-1 text-gray-400 hover:text-red-500 transition-colors"
onClick={(e) => { title="삭제"
e.stopPropagation();
onSelect();
}}
> >
<Eye className="h-4 w-4" /> <Trash2 className="h-3.5 w-3.5" />
</button> </button>
<button )}
type="button"
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
title="다운로드"
onClick={(e) => {
e.stopPropagation();
// TODO: 다운로드 기능
}}
>
<Download className="h-4 w-4" />
</button>
</div>
</div> </div>
); );
} }

View File

@@ -3,13 +3,120 @@
import React from 'react'; import React from 'react';
import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { StandardDocument } from '../types'; import type { StandardDocument, TemplateDocument } from '../types';
interface Day1DocumentViewerProps { interface Day1DocumentViewerProps {
document: StandardDocument | null; document: StandardDocument | null;
uploadedFile?: TemplateDocument | null;
isMock?: boolean;
} }
export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) { export function Day1DocumentViewer({ document, uploadedFile, isMock }: Day1DocumentViewerProps) {
// 업로드된 파일이 선택된 경우
if (uploadedFile) {
const isPdf = uploadedFile.mimeType === 'application/pdf';
const viewUrl = `/api/proxy/files/${uploadedFile.id}/view`;
const downloadUrl = `/api/proxy/files/${uploadedFile.id}/download`;
// Google Docs Viewer용 공개 URL 생성 (개발/운영 서버에서만 동작)
const isLocalhost = typeof window !== 'undefined' && (
window.location.hostname === 'localhost' ||
window.location.hostname.endsWith('.sam.kr')
);
const publicFileUrl = typeof window !== 'undefined'
? `${window.location.origin}${viewUrl}`
: '';
const googleViewerUrl = `https://docs.google.com/gview?url=${encodeURIComponent(publicFileUrl)}&embedded=true`;
const isOfficeDoc = [
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
].includes(uploadedFile.mimeType);
return (
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
{/* 헤더 */}
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200 flex items-center justify-between">
<div className="flex items-center gap-2">
<div className={`w-6 h-6 sm:w-8 sm:h-8 rounded flex items-center justify-center ${
isPdf ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
}`}>
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
</div>
<div>
<h3 className="font-medium text-gray-900 text-xs sm:text-sm truncate max-w-[200px]">
{uploadedFile.displayName}
</h3>
<p className="text-[10px] sm:text-xs text-gray-500">
{(uploadedFile.fileSize / (1024 * 1024)).toFixed(1)} MB
</p>
</div>
</div>
<div className="flex items-center gap-1">
<a
href={viewUrl}
target="_blank"
rel="noopener noreferrer"
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="새 탭에서 보기"
>
<Maximize2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</a>
<a
href={downloadUrl}
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="다운로드"
>
<Download className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
</a>
</div>
</div>
{/* 문서 미리보기 */}
<div className="flex-1 bg-gray-200 overflow-auto">
{isPdf ? (
<iframe
src={viewUrl}
className="w-full h-full border-0"
title={uploadedFile.displayName}
/>
) : isOfficeDoc && !isLocalhost ? (
<iframe
src={googleViewerUrl}
className="w-full h-full border-0"
title={uploadedFile.displayName}
/>
) : (
<div className="flex items-center justify-center h-full text-gray-400">
<div className="text-center">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p className="text-sm">
{isOfficeDoc && isLocalhost
? '로컬 환경에서는 Office 문서 미리보기가 지원되지 않습니다'
: '미리보기를 지원하지 않는 파일 형식입니다'}
</p>
<a
href={downloadUrl}
className="inline-block mt-2 text-xs text-blue-600 hover:text-blue-800 underline"
>
</a>
</div>
</div>
)}
</div>
{/* 푸터 */}
<div className="bg-gray-100 px-2 sm:px-4 py-1.5 sm:py-2 border-t border-gray-200">
<span className="text-[10px] sm:text-xs text-gray-500 truncate">
{uploadedFile.displayName}
</span>
</div>
</div>
);
}
if (!document) { if (!document) {
return ( return (
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center"> <div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
@@ -38,7 +145,14 @@ export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
<FileText className="h-3 w-3 sm:h-4 sm:w-4" /> <FileText className="h-3 w-3 sm:h-4 sm:w-4" />
</div> </div>
<div> <div>
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3> <div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</div>
<p className="text-[10px] sm:text-xs text-gray-500"> <p className="text-[10px] sm:text-xs text-gray-500">
{document.version !== '-' && <span className="mr-2">{document.version}</span>} {document.version !== '-' && <span className="mr-2">{document.version}</span>}
{document.date} {document.date}

View File

@@ -1,16 +1,21 @@
"use client"; "use client";
import React, { useState } from 'react'; import React, { useState, useRef } from 'react';
import { import {
FileText, CheckCircle, ChevronDown, ChevronUp, FileText, CheckCircle, ChevronDown, ChevronUp,
Eye, Truck, Calendar, ClipboardCheck, Box, FileCheck Eye, Truck, Calendar, ClipboardCheck, Box, FileCheck,
Upload, Download, Loader2
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner';
import { Document, DocumentItem } from '../types'; import { Document, DocumentItem } from '../types';
import { downloadFileById } from '@/lib/utils/fileDownload';
interface DocumentListProps { interface DocumentListProps {
documents: Document[]; documents: Document[];
routeCode: string | null; routeCode: string | null;
onViewDocument: (doc: Document, item?: DocumentItem) => void; onViewDocument: (doc: Document, item?: DocumentItem) => void;
onQualityFileUpload?: (qualityDocumentId: string, file: File) => Promise<boolean>;
isMock?: boolean;
} }
const getIcon = (type: string) => { const getIcon = (type: string) => {
@@ -27,11 +32,76 @@ const getIcon = (type: string) => {
} }
}; };
export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentListProps) => { /** 파일 크기를 읽기 쉬운 형식으로 변환 */
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
export const DocumentList = ({ documents, routeCode, onViewDocument, onQualityFileUpload, isMock }: DocumentListProps) => {
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const [uploadingDocId, setUploadingDocId] = useState<string | null>(null);
const [downloadingDocId, setDownloadingDocId] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadTargetDocId = useRef<string | null>(null); // doc.id (상태 추적용)
const uploadApiDocId = useRef<string | null>(null); // 실제 DB ID (API 호출용)
// 품질관리서 파일 다운로드
const handleQualityDownload = async (doc: Document) => {
if (!doc.fileId) return;
setDownloadingDocId(doc.id);
try {
await downloadFileById(doc.fileId, doc.fileName);
} catch {
toast.error('파일 다운로드에 실패했습니다.');
} finally {
setDownloadingDocId(null);
}
};
// 업로드 버튼 클릭 → hidden input 트리거
const handleUploadClick = (e: React.MouseEvent, docId: string, apiDocId?: string) => {
e.stopPropagation();
uploadTargetDocId.current = docId; // 상태 추적용
uploadApiDocId.current = apiDocId || docId; // API 호출용 (품질관리서는 items[0].id)
fileInputRef.current?.click();
};
// 파일 선택 후 업로드 실행
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
const trackingId = uploadTargetDocId.current;
const apiId = uploadApiDocId.current;
if (!file || !trackingId || !apiId || !onQualityFileUpload) return;
setUploadingDocId(trackingId);
try {
const success = await onQualityFileUpload(apiId, file);
if (success) {
toast.success('파일이 업로드되었습니다.');
}
} finally {
setUploadingDocId(null);
uploadTargetDocId.current = null;
uploadApiDocId.current = null;
// input 초기화 (같은 파일 재선택 가능하도록)
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// 문서 카테고리 클릭 핸들러 // 문서 카테고리 클릭 핸들러
const handleDocClick = (doc: Document) => { const handleDocClick = (doc: Document) => {
// 품질관리서: 파일이 있으면 다운로드, 없으면 무시
if (doc.type === 'quality') {
if (doc.fileId) {
handleQualityDownload(doc);
}
return;
}
const hasItems = doc.items && doc.items.length > 0; const hasItems = doc.items && doc.items.length > 0;
if (!hasItems) return; if (!hasItems) return;
@@ -50,52 +120,115 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
onViewDocument(doc, item); onViewDocument(doc, item);
}; };
// 품질관리서 서브텍스트 렌더링
const renderQualitySubText = (doc: Document) => {
if (doc.fileId && doc.fileName) {
return (
<span className="flex items-center gap-1">
<Download size={10} className="text-purple-500" />
<span className="text-purple-600 truncate max-w-[150px]" title={doc.fileName}>
{doc.fileName}
</span>
{doc.fileSize && (
<span className="text-gray-400 ml-1">({formatFileSize(doc.fileSize)})</span>
)}
</span>
);
}
return <span className="text-gray-400"> </span>;
};
return ( return (
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden"> <div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4"> {/* hidden file input for quality document upload */}
{' '} <input
{routeCode && ( ref={fileInputRef}
<span className="text-gray-400 font-normal ml-1">({routeCode})</span> type="file"
className="hidden"
onChange={handleFileChange}
/>
<div className="flex items-center gap-2 mb-3 sm:mb-4">
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
{' '}
{routeCode && (
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
)}
</h2>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)} )}
</h2> </div>
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1"> <div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
{!routeCode ? ( {!routeCode ? (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm"> <div className="flex items-center justify-center h-32 text-gray-400 text-sm">
. .
</div> </div>
) : ( ) : (
documents.map((doc) => { documents.map((doc) => {
const isExpanded = expandedId === doc.id; const isExpanded = expandedId === doc.id;
const isQuality = doc.type === 'quality';
const hasItems = doc.items && doc.items.length > 0; const hasItems = doc.items && doc.items.length > 0;
const hasMultipleItems = doc.items && doc.items.length > 1; const hasMultipleItems = doc.items && doc.items.length > 1;
const isUploading = uploadingDocId === doc.id;
const isDownloading = downloadingDocId === doc.id;
// 품질관리서: 파일 유무로 클릭 가능 여부 결정
// 나머지: 아이템 유무로 결정
const isClickable = isQuality ? !!doc.fileId : hasItems;
return ( return (
<div key={doc.id} className="border border-gray-200 rounded-lg overflow-hidden"> <div key={doc.id} className="border border-gray-200 rounded-lg overflow-hidden">
<div <div
onClick={() => handleDocClick(doc)} onClick={() => handleDocClick(doc)}
className={`p-3 sm:p-4 flex justify-between items-center transition-colors ${ className={`p-3 sm:p-4 flex justify-between items-center transition-colors ${
hasItems ? 'cursor-pointer hover:bg-gray-50' : 'cursor-default opacity-60' isClickable ? 'cursor-pointer hover:bg-gray-50' : 'cursor-default opacity-60'
} ${isExpanded ? 'bg-green-50' : 'bg-white'}`} } ${isExpanded ? 'bg-green-50' : 'bg-white'}`}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 min-w-0 flex-1">
<div className={`p-2 rounded-lg ${isExpanded ? 'bg-white' : 'bg-gray-100'}`}> <div className={`p-2 rounded-lg flex-shrink-0 ${isExpanded ? 'bg-white' : 'bg-gray-100'}`}>
{getIcon(doc.type)} {(isUploading || isDownloading) ? (
<Loader2 className="text-purple-600 animate-spin" size={20} />
) : (
getIcon(doc.type)
)}
</div> </div>
<div> <div className="min-w-0 flex-1">
<h3 className="font-bold text-gray-800 text-sm">{doc.title}</h3> <h3 className="font-bold text-gray-800 text-sm">{doc.title}</h3>
<p className="text-xs text-gray-500"> <p className="text-xs text-gray-500">
{doc.count > 0 ? `${doc.count}건의 서류` : '서류 없음'} {isQuality
? renderQualitySubText(doc)
: doc.count > 0 ? `${doc.count}건의 서류` : '서류 없음'
}
</p> </p>
</div> </div>
</div> </div>
{hasMultipleItems && (
isExpanded ? ( <div className="flex items-center gap-1 flex-shrink-0">
<ChevronUp size={16} className="text-gray-400" /> {/* 품질관리서 업로드 버튼 */}
) : ( {isQuality && onQualityFileUpload && doc.items?.[0]?.id && (
<ChevronDown size={16} className="text-gray-400" /> <button
) type="button"
)} onClick={(e) => handleUploadClick(e, doc.id, doc.items![0].id)}
disabled={isUploading}
className="p-1.5 rounded-md hover:bg-purple-50 text-purple-500 hover:text-purple-700 transition-colors disabled:opacity-50"
title={doc.fileId ? '파일 교체' : '파일 업로드'}
>
<Upload size={16} />
</button>
)}
{/* 기존: 여러 아이템일 때 펼치기/접기 아이콘 */}
{!isQuality && hasMultipleItems && (
isExpanded ? (
<ChevronUp size={16} className="text-gray-400" />
) : (
<ChevronDown size={16} className="text-gray-400" />
)
)}
</div>
</div> </div>
{isExpanded && hasMultipleItems && ( {isExpanded && hasMultipleItems && (
@@ -114,7 +247,7 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
{item.code && ( {item.code && (
<> <>
<span className="mx-1">|</span> <span className="mx-1">|</span>
: {item.code} {item.code}
</> </>
)} )}
</div> </div>
@@ -131,4 +264,4 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
</div> </div>
</div> </div>
); );
}; };

View File

@@ -1,78 +1,130 @@
"use client"; "use client";
/**
* InspectionModal (QMS 전용)
*
* 수입검사, 수주서, 납품확인서, 출고증, 품질관리서 등
* 아직 독립 모달이 없는 문서 타입만 처리.
*
* 작업일지(log), 중간검사(report), 제품검사(product)는
* 각각 WorkLogModal, InspectionReportModal, ProductInspectionViewModal로 분리됨.
*/
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { AlertCircle, Loader2, Save } from 'lucide-react'; import { AlertCircle, Loader2, Save } from 'lucide-react';
import { DocumentViewer } from '@/components/document-system'; import { DocumentViewer } from '@/components/document-system';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Document, DocumentItem } from '../types'; import { Document, DocumentItem } from '../types';
import { MOCK_SHIPMENT_DETAIL } from '../mockData'; import { getDocumentDetail } from '../actions';
// 기존 문서 컴포넌트 import // 기존 문서 컴포넌트 import
import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation'; import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation';
import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip'; import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip';
import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/types';
// 수주서 문서 컴포넌트 import // 수주서 문서 컴포넌트 import
import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument'; import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument';
import type { ProductInfo } from '@/components/orders/documents/OrderDocumentModal';
import type { OrderItem } from '@/components/orders/actions';
// 품질검사 문서 컴포넌트 import // 품질검사 문서 컴포넌트 import
import { import {
ImportInspectionDocument, ImportInspectionDocument,
JointbarInspectionDocument,
QualityDocumentUploader, QualityDocumentUploader,
} from './documents'; } from './documents';
// 제품검사 성적서 (신규 양식) import
import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument';
import { mockReportInspectionItems } from '@/components/quality/InspectionManagement/mockData';
import type { InspectionReportDocument as InspectionReportDocumentType } from '@/components/quality/InspectionManagement/types';
import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument'; import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument';
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
import {
ScreenWorkLogContent,
SlatWorkLogContent,
BendingWorkLogContent,
ScreenInspectionContent,
SlatInspectionContent,
BendingInspectionContent,
} from '@/components/production/WorkOrders/documents';
import type { WorkOrder } from '@/components/production/WorkOrders/types';
// 검사 템플릿 API // 검사 템플릿 API
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions'; import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
/** /**
* 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환 * 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환
* *
* field_key 패턴: * 두 가지 저장 형식을 모두 지원:
* - {itemId}_n{1,2,3} → numeric 측정값 * - 정규화 형식: section_id + row_index로 항목 식별, field_key는 "n1", "n1_ok" 등
* - {itemId}_okng_n{1,2,3} → OK/NG 값 * - 레거시 형식: field_key에 item.id 포함, 예: "${itemId}_n1", "${itemId}_okng_n1"
* - {itemId}_result → 항목별 판정
*/ */
function parseSavedDataToInitialValues( function parseSavedDataToInitialValues(
tmpl: ImportInspectionTemplate, tmpl: ImportInspectionTemplate,
docData: Array<{ field_key: string; field_value: string | null }> docData: Array<{ field_key: string; field_value: string | null; section_id?: number | null; row_index?: number }>,
sections?: Array<{ id: number; items: Array<{ id: number }> }>
): InspectionItemValue[] { ): InspectionItemValue[] {
// field_key → value 맵 생성 // (sectionId, rowIndex) → inspectionItem.id 역매핑 구축
const dataMap = new Map<string, string>(); const reverseMap = new Map<string, string>();
if (sections) {
for (const section of sections) {
section.items.forEach((sItem, idx) => {
reverseMap.set(`${section.id}_${idx}`, String(sItem.id));
});
}
}
// 정규화 형식: itemId → { field_key → value }
const normalizedMap = new Map<string, Map<string, string>>();
// 레거시 형식: "${itemId}_n1" → value
const legacyMap = new Map<string, string>();
for (const d of docData) { for (const d of docData) {
if (d.field_value) dataMap.set(d.field_key, d.field_value); if (!d.field_value) continue;
const key = d.field_key;
const val = d.field_value;
// 전역 필드는 스킵
if (key === 'overall_result' || key === 'footer_judgement') continue;
if (key === 'remark' || key === 'footer_remark') continue;
// 정규화 형식: section_id가 있으면 역매핑으로 item 찾기
if (d.section_id != null && reverseMap.size > 0) {
const itemId = reverseMap.get(`${d.section_id}_${d.row_index ?? 0}`);
if (itemId) {
if (!normalizedMap.has(itemId)) normalizedMap.set(itemId, new Map());
normalizedMap.get(itemId)!.set(key, val);
}
continue;
}
// 레거시 형식 fallback
legacyMap.set(key, val);
} }
return tmpl.inspectionItems.map((item) => { return tmpl.inspectionItems.map((item) => {
const isOkng = item.measurementType === 'okng'; const isOkng = item.measurementType === 'okng';
const measurements: (number | 'OK' | 'NG' | null)[] = Array(item.measurementCount).fill(null); const measurements: (number | 'OK' | 'NG' | null)[] = Array(item.measurementCount).fill(null);
// 정규화 형식 우선 시도
const nData = normalizedMap.get(item.id);
if (nData && nData.size > 0) {
for (let n = 0; n < item.measurementCount; n++) {
if (isOkng) {
const okVal = nData.get(`n${n + 1}_ok`);
const ngVal = nData.get(`n${n + 1}_ng`);
if (okVal === 'OK') measurements[n] = 'OK';
else if (ngVal === 'NG') measurements[n] = 'NG';
} else {
const val = nData.get(`n${n + 1}`);
if (val) {
const num = parseFloat(val);
measurements[n] = isNaN(num) ? null : num;
}
}
}
const resultVal = nData.get('value');
let result: 'OK' | 'NG' | null = null;
if (resultVal === '적합' || resultVal === 'ok') result = 'OK';
else if (resultVal === '부적합' || resultVal === 'ng') result = 'NG';
return { itemId: item.id, measurements, result };
}
// 레거시 형식 fallback
for (let n = 0; n < item.measurementCount; n++) { for (let n = 0; n < item.measurementCount; n++) {
if (isOkng) { if (isOkng) {
const val = dataMap.get(`${item.id}_okng_n${n + 1}`); const val = legacyMap.get(`${item.id}_okng_n${n + 1}`);
if (val === 'ok') measurements[n] = 'OK'; if (val === 'ok') measurements[n] = 'OK';
else if (val === 'ng') measurements[n] = 'NG'; else if (val === 'ng') measurements[n] = 'NG';
} else { } else {
const val = dataMap.get(`${item.id}_n${n + 1}`); const val = legacyMap.get(`${item.id}_n${n + 1}`);
if (val) { if (val) {
const num = parseFloat(val); const num = parseFloat(val);
measurements[n] = isNaN(num) ? null : num; measurements[n] = isNaN(num) ? null : num;
@@ -80,8 +132,7 @@ function parseSavedDataToInitialValues(
} }
} }
// 항목별 판정 const resultVal = legacyMap.get(`${item.id}_result`);
const resultVal = dataMap.get(`${item.id}_result`);
let result: 'OK' | 'NG' | null = null; let result: 'OK' | 'NG' | null = null;
if (resultVal === 'ok') result = 'OK'; if (resultVal === 'ok') result = 'OK';
else if (resultVal === 'ng') result = 'NG'; else if (resultVal === 'ng') result = 'NG';
@@ -96,15 +147,14 @@ interface InspectionModalProps {
document: Document | null; document: Document | null;
documentItem: DocumentItem | null; documentItem: DocumentItem | null;
// 수입검사 템플릿 로드용 추가 props // 수입검사 템플릿 로드용 추가 props
itemId?: number; // 품목 ID (실제 API로 템플릿 조회 시 사용) itemId?: number;
itemName?: string; itemName?: string;
specification?: string; specification?: string;
supplier?: string; supplier?: string;
inspector?: string; // 검사자 (현재 로그인 사용자) inspector?: string;
inspectorDept?: string; // 검사자 부서 inspectorDept?: string;
lotSize?: number; // 로트크기 (입고수량) lotSize?: number;
materialNo?: string; // 자재번호 materialNo?: string;
// 읽기 전용 모드 (QMS 심사 확인용)
readOnly?: boolean; readOnly?: boolean;
} }
@@ -112,11 +162,8 @@ interface InspectionModalProps {
const DOCUMENT_INFO: Record<string, { label: string; hasTemplate: boolean; color: string }> = { const DOCUMENT_INFO: Record<string, { label: string; hasTemplate: boolean; color: string }> = {
import: { label: '수입검사 성적서', hasTemplate: true, color: 'text-green-600' }, import: { label: '수입검사 성적서', hasTemplate: true, color: 'text-green-600' },
order: { label: '수주서', hasTemplate: true, color: 'text-blue-600' }, order: { label: '수주서', hasTemplate: true, color: 'text-blue-600' },
log: { label: '작업일지', hasTemplate: true, color: 'text-orange-500' },
report: { label: '중간검사 성적서', hasTemplate: true, color: 'text-blue-500' },
confirmation: { label: '납품확인서', hasTemplate: true, color: 'text-red-500' }, confirmation: { label: '납품확인서', hasTemplate: true, color: 'text-red-500' },
shipping: { label: '출고증', hasTemplate: true, color: 'text-gray-600' }, shipping: { label: '출고증', hasTemplate: true, color: 'text-gray-600' },
product: { label: '제품검사 성적서', hasTemplate: true, color: 'text-green-500' },
quality: { label: '품질관리서', hasTemplate: false, color: 'text-purple-600' }, quality: { label: '품질관리서', hasTemplate: false, color: 'text-purple-600' },
}; };
@@ -136,7 +183,7 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D
)} )}
{docItem?.code && ( {docItem?.code && (
<p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4"> <p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4">
: {docItem.code} : {docItem.code}
</p> </p>
)} )}
<div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200"> <div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
@@ -147,77 +194,53 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D
); );
}; };
// QMS용 수주서 Mock 데이터 // eslint-disable-next-line @typescript-eslint/no-explicit-any
const QMS_MOCK_PRODUCTS: ProductInfo[] = [ type DocumentDetailData = Record<string, any>;
{ productName: '방화 스크린 셔터 (표준형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 5, floor: '1F', code: 'FSS-01' },
{ productName: '방화 스크린 셔터 (방화형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 3, floor: '2F', code: 'FSS-02' },
];
const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [
{ id: 'mt-1', itemCode: 'MT-001', itemName: '모터(380V 단상)', specification: '150K', type: '모터', quantity: 8, unit: 'EA', unitPrice: 120000, supplyAmount: 960000, taxAmount: 96000, totalAmount: 1056000, sortOrder: 1 },
{ id: 'br-1', itemCode: 'BR-001', itemName: '브라켓트', specification: '380X180 [2-4"]', type: '브라켓', quantity: 16, unit: 'EA', unitPrice: 15000, supplyAmount: 240000, taxAmount: 24000, totalAmount: 264000, sortOrder: 2 },
{ id: 'gr-1', itemCode: 'GR-001', itemName: '가이드레일 백면형 (120X70)', specification: 'EGI 1.5ST', type: '가이드레일', quantity: 16, unit: 'EA', unitPrice: 25000, supplyAmount: 400000, taxAmount: 40000, totalAmount: 440000, width: 120, height: 2500, sortOrder: 3 },
{ id: 'cs-1', itemCode: 'CS-001', itemName: '케이스(셔터박스)', specification: 'EGI 1.5ST 380X180', type: '케이스', quantity: 8, unit: 'EA', unitPrice: 35000, supplyAmount: 280000, taxAmount: 28000, totalAmount: 308000, width: 380, height: 180, sortOrder: 4 },
{ id: 'bf-1', itemCode: 'BF-001', itemName: '하단마감재', specification: 'EGI 1.5ST', type: '하단마감재', quantity: 8, unit: 'EA', unitPrice: 18000, supplyAmount: 144000, taxAmount: 14400, totalAmount: 158400, sortOrder: 5 },
];
// QMS용 제품검사 성적서 Mock 데이터 /**
const QMS_MOCK_REPORT_DATA: InspectionReportDocumentType = { * API 출고 상세 응답 → ShipmentDetail 타입 매핑
documentNumber: 'RPT-KD-SS-2024-530', * ShipmentOrderDocument 내부에서 아직 MOCK_ 데이터를 사용하므로
createdDate: '2024-09-24', * 여기서는 헤더 정보 매핑만 수행 (Phase 2에서 완전 전환)
approvalLine: [ */
{ role: '작성', name: '김검사', department: '품질관리부' }, function mapShipmentApiToDetail(api: DocumentDetailData): ShipmentDetail {
{ role: '승인', name: '박승인', department: '품질관리부' }, return {
], id: String(api.id || ''),
productName: '방화스크린', shipmentNo: api.shipment_no || '-',
productLotNo: 'KD-SS-240924-19', lotNo: api.lot_no || '-',
productCode: 'WY-SC780', siteName: api.site_name || '-',
lotSize: '8', customerName: api.customer_name || '-',
client: '삼성물산(주)', customerGrade: api.customer_grade || '-',
inspectionDate: '2024-09-26', status: api.status || 'scheduled',
siteName: '강남 아파트 단지', scheduledDate: api.scheduled_date || '-',
inspector: '김검사', deliveryMethod: api.delivery_method || 'loading',
inspectionItems: mockReportInspectionItems, freightCost: api.shipping_cost,
specialNotes: '', receiver: api.receiver,
finalJudgment: '합격', receiverContact: api.receiver_contact,
}; deliveryAddress: api.delivery_address || '-',
vehicleNo: api.vehicle_no,
// QMS용 작업일지 Mock WorkOrder 생성 driverName: api.driver_name,
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({ driverContact: api.driver_contact,
id: 'qms-wo-1', remarks: api.remarks,
workOrderNo: 'KD-WO-240924-01', vehicleDispatches: (api.vehicle_dispatches || []).map((d: DocumentDetailData, i: number) => ({
lotNo: 'KD-SS-240924-19', id: String(i),
processId: 1, logisticsCompany: d.logistics_company || '-',
processName: subType === 'slat' ? '슬랫' : subType === 'bending' ? '절곡' : '스크린', arrivalDateTime: d.arrival_datetime || '-',
processCode: subType || 'screen', tonnage: d.tonnage || '-',
processType: (subType || 'screen') as 'screen' | 'slat' | 'bending', vehicleNo: d.vehicle_no || '-',
status: 'in_progress', driverContact: d.driver_contact || '-',
client: '삼성물산(주)', remarks: d.remarks || '',
projectName: '강남 아파트 단지', })),
dueDate: '2024-10-05', // Phase 2: product_groups/other_parts 실 데이터 매핑 예정
assignee: '김작업', productGroups: [],
assignees: [ otherParts: [],
{ id: '1', name: '김작업', isPrimary: true }, // 하위 호환 필드 (최소값)
{ id: '2', name: '이생산', isPrimary: false }, products: [],
], priority: 'normal',
orderDate: '2024-09-20', depositConfirmed: false,
scheduledDate: '2024-09-24', invoiceIssued: false,
shipmentDate: '2024-10-04', canShip: false,
salesOrderDate: '2024-09-18', } as ShipmentDetail;
isAssigned: true, }
isStarted: true,
priority: 3,
priorityLabel: '긴급',
shutterCount: 5,
department: '생산부',
items: [
{ id: '1', no: 1, status: 'completed', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '3000×2500', quantity: 2, unit: 'EA', orderNodeId: null, orderNodeName: '' },
{ id: '2', no: 2, status: 'in_progress', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '3000×2500', quantity: 3, unit: 'EA', orderNodeId: null, orderNodeName: '' },
{ id: '3', no: 3, status: 'waiting', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12000×4500', quantity: 1, unit: 'EA', orderNodeId: null, orderNodeName: '' },
],
currentStep: 2,
issues: [],
note: '품질 검수 철저히 진행',
});
// 로딩 컴포넌트 // 로딩 컴포넌트
const LoadingDocument = () => ( const LoadingDocument = () => (
@@ -245,9 +268,9 @@ const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () =>
); );
/** /**
* InspectionModal V2 * InspectionModal
* - DocumentViewer 시스템 사용 * - 수입검사, 수주서, 납품확인서, 출고증, 품질관리서만 처리
* - 수입검사: 모달 열릴 때 API로 템플릿 로드 (Lazy Loading) * - 작업일지/중간검사/제품검사는 각각 독립 모달로 분리됨
*/ */
export const InspectionModal = ({ export const InspectionModal = ({
isOpen, isOpen,
@@ -274,13 +297,38 @@ export const InspectionModal = ({
const importDocRef = useRef<ImportInspectionRef>(null); const importDocRef = useRef<ImportInspectionRef>(null);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
// 수주서/출고증/납품확인서 실 데이터 상태
const [docDetailData, setDocDetailData] = useState<DocumentDetailData | null>(null);
const [isLoadingDocDetail, setIsLoadingDocDetail] = useState(false);
// 수주서/출고증/납품확인서 데이터 로드
useEffect(() => {
if (!isOpen || !doc) return;
if (!['order', 'confirmation', 'shipping'].includes(doc.type)) return;
const docItemId = documentItem?.id || doc.id;
if (!docItemId) return;
setIsLoadingDocDetail(true);
getDocumentDetail(doc.type, docItemId)
.then((result) => {
if (result.success && result.data) {
const raw = result.data as DocumentDetailData;
setDocDetailData(raw?.data ?? raw);
}
})
.finally(() => setIsLoadingDocDetail(false));
return () => {
setDocDetailData(null);
};
}, [isOpen, doc?.type, doc?.id, documentItem?.id]);
// 수입검사 템플릿 로드 (모달 열릴 때) // 수입검사 템플릿 로드 (모달 열릴 때)
useEffect(() => { useEffect(() => {
// itemId가 있으면 실제 API로 조회, 없으면 itemName/specification으로 mock 조회
if (isOpen && doc?.type === 'import' && (itemId || (itemName && specification))) { if (isOpen && doc?.type === 'import' && (itemId || (itemName && specification))) {
loadInspectionTemplate(); loadInspectionTemplate();
} }
// 모달 닫힐 때 상태 초기화
if (!isOpen) { if (!isOpen) {
setImportTemplate(null); setImportTemplate(null);
setImportInitialValues(undefined); setImportInitialValues(undefined);
@@ -289,7 +337,6 @@ export const InspectionModal = ({
}, [isOpen, doc?.type, itemId, itemName, specification]); }, [isOpen, doc?.type, itemId, itemName, specification]);
const loadInspectionTemplate = async () => { const loadInspectionTemplate = async () => {
// itemId가 있으면 실제 API 호출, 없으면 itemName/specification 필요
if (!itemId && (!itemName || !specification)) return; if (!itemId && (!itemName || !specification)) return;
setIsLoadingTemplate(true); setIsLoadingTemplate(true);
@@ -311,10 +358,19 @@ export const InspectionModal = ({
const tmpl = result.data as ImportInspectionTemplate; const tmpl = result.data as ImportInspectionTemplate;
setImportTemplate(tmpl); setImportTemplate(tmpl);
// 저장된 측정값을 initialValues로 변환
const docData = result.resolveData?.document?.data; const docData = result.resolveData?.document?.data;
if (docData && docData.length > 0) { if (docData && docData.length > 0) {
const values = parseSavedDataToInitialValues(tmpl, docData.map((d: { field_key: string; field_value?: string | null }) => ({ field_key: d.field_key, field_value: d.field_value ?? null }))); const sections = result.resolveData?.template?.sections;
const values = parseSavedDataToInitialValues(
tmpl,
docData.map((d: { field_key: string; field_value?: string | null; section_id?: number | null; row_index?: number }) => ({
field_key: d.field_key,
field_value: d.field_value ?? null,
section_id: d.section_id,
row_index: d.row_index,
})),
sections
);
setImportInitialValues(values); setImportInitialValues(values);
} else { } else {
setImportInitialValues(undefined); setImportInitialValues(undefined);
@@ -330,11 +386,11 @@ export const InspectionModal = ({
} }
}; };
// 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함) // 수입검사 저장 핸들러
const handleImportSave = useCallback(async () => { const handleImportSave = useCallback(async () => {
if (!importDocRef.current) return; if (!importDocRef.current) return;
const data = importDocRef.current.getInspectionData(); const _data = importDocRef.current.getInspectionData();
setIsSaving(true); setIsSaving(true);
try { try {
// TODO: 실제 저장 API 연동 // TODO: 실제 저장 API 연동
@@ -350,52 +406,16 @@ export const InspectionModal = ({
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' }; const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
const subtitle = documentItem const subtitle = documentItem
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` 로트: ${documentItem.code}` : ''}` ? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` ${documentItem.code}` : ''}`
: docInfo.label; : docInfo.label;
// 품질관리서 PDF 업로드 핸들러 // 품질관리서 PDF 업로드 핸들러
const handleQualityFileUpload = (file: File) => { const handleQualityFileUpload = (_file: File) => {
}; };
const handleQualityFileDelete = () => { const handleQualityFileDelete = () => {
}; };
// 작업일지 공정별 렌더링
const renderWorkLogDocument = () => {
const subType = documentItem?.subType;
const mockOrder = createQmsMockWorkOrder(subType);
switch (subType) {
case 'screen':
return <ScreenWorkLogContent data={mockOrder} />;
case 'slat':
return <SlatWorkLogContent data={mockOrder} />;
case 'bending':
return <BendingWorkLogContent data={mockOrder} />;
default:
// subType 미지정 시 스크린 기본
return <ScreenWorkLogContent data={mockOrder} />;
}
};
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
const renderReportDocument = () => {
const subType = documentItem?.subType;
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
switch (subType) {
case 'screen':
return <ScreenInspectionContent data={mockOrder} readOnly />;
case 'bending':
return <BendingInspectionContent data={mockOrder} readOnly />;
case 'slat':
return <SlatInspectionContent data={mockOrder} readOnly />;
case 'jointbar':
return <JointbarInspectionDocument />;
default:
return <ScreenInspectionContent data={mockOrder} readOnly />;
}
};
// 수입검사 문서 렌더링 (Lazy Loading) // 수입검사 문서 렌더링 (Lazy Loading)
const renderImportInspectionDocument = () => { const renderImportInspectionDocument = () => {
if (isLoadingTemplate) { if (isLoadingTemplate) {
@@ -406,7 +426,6 @@ export const InspectionModal = ({
return <ErrorDocument message={templateError} onRetry={loadInspectionTemplate} />; return <ErrorDocument message={templateError} onRetry={loadInspectionTemplate} />;
} }
// 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용
return ( return (
<ImportInspectionDocument <ImportInspectionDocument
ref={importDocRef} ref={importDocRef}
@@ -421,41 +440,47 @@ export const InspectionModal = ({
// 문서 타입에 따른 컨텐츠 렌더링 // 문서 타입에 따른 컨텐츠 렌더링
const renderDocumentContent = () => { const renderDocumentContent = () => {
switch (doc.type) { switch (doc.type) {
case 'order': case 'order': {
if (isLoadingDocDetail) return <LoadingDocument />;
if (!docDetailData) return <ErrorDocument message="수주서 데이터를 불러올 수 없습니다." />;
const d = docDetailData;
return ( return (
<SalesOrderDocument <SalesOrderDocument
orderNumber="KD-SS-240924-19" orderNumber={d.order_no || '-'}
documentNumber="KD-SS-240924-19" documentNumber={d.order_no || '-'}
certificationNumber="KD-SS-240924-19" certificationNumber={d.order_no || '-'}
orderDate="2024-09-24" orderDate={d.received_at || '-'}
client="삼성물산(주)" client={d.client_name || '-'}
siteName="강남 아파트 단지" siteName={d.site_name || '-'}
manager="김담당" manager={d.manager_name || '-'}
managerContact="010-1234-5678" managerContact={d.client_contact || '-'}
deliveryRequestDate="2024-10-05" deliveryRequestDate={d.delivery_date || '-'}
expectedShipDate="2024-10-04" deliveryMethod={d.delivery_method_code || '-'}
deliveryMethod="직접배차" address={[d.shipping_address, d.shipping_address_detail].filter(Boolean).join(' ') || '-'}
address="서울시 강남구 테헤란로 123" recipientName={d.receiver || '-'}
recipientName="김인수" recipientContact={d.receiver_contact || '-'}
recipientContact="010-9876-5432" shutterCount={d.nodes_count || 0}
shutterCount={8} remarks={d.remarks}
products={QMS_MOCK_PRODUCTS} productRows={d.products || []}
items={QMS_MOCK_ORDER_ITEMS} motorsLeft={d.motors?.left || []}
remarks="납기일 엄수 요청" motorsRight={d.motors?.right || []}
bendingParts={d.bending_parts || []}
subsidiaryParts={d.subsidiary_parts || []}
categoryCode={d.category_code}
/> />
); );
case 'log': }
return renderWorkLogDocument();
case 'confirmation': case 'confirmation':
return <DeliveryConfirmation data={MOCK_SHIPMENT_DETAIL} />;
case 'shipping': case 'shipping':
return <ShippingSlip data={MOCK_SHIPMENT_DETAIL} />; if (isLoadingDocDetail) return <LoadingDocument />;
if (!docDetailData) return <ErrorDocument message="출고 데이터를 불러올 수 없습니다." />;
// TODO Phase 2: ShipmentOrderDocument도 실 데이터로 전환 시 여기서 매핑
// 현재는 ShipmentOrderDocument 내부 mock data를 사용하되 헤더 정보만 전달
return doc.type === 'confirmation'
? <DeliveryConfirmation data={mapShipmentApiToDetail(docDetailData)} />
: <ShippingSlip data={mapShipmentApiToDetail(docDetailData)} />;
case 'import': case 'import':
return renderImportInspectionDocument(); return renderImportInspectionDocument();
case 'product':
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
case 'report':
return renderReportDocument();
case 'quality': case 'quality':
return ( return (
<QualityDocumentUploader <QualityDocumentUploader
@@ -498,4 +523,4 @@ export const InspectionModal = ({
{renderDocumentContent()} {renderDocumentContent()}
</DocumentViewer> </DocumentViewer>
); );
}; };

View File

@@ -8,13 +8,21 @@ interface ReportListProps {
reports: InspectionReport[]; reports: InspectionReport[];
selectedId: string | null; selectedId: string | null;
onSelect: (report: InspectionReport) => void; onSelect: (report: InspectionReport) => void;
isMock?: boolean;
} }
export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) => { export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportListProps) => {
return ( return (
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col"> <div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col">
<div className="flex items-center justify-between mb-3 sm:mb-4"> <div className="flex items-center justify-between mb-3 sm:mb-4">
<h2 className="font-bold text-sm sm:text-lg text-gray-800"> </h2> <div className="flex items-center gap-2">
<h2 className="font-bold text-sm sm:text-lg text-gray-800"> </h2>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)}
</div>
<span className="bg-blue-100 text-blue-800 text-[10px] sm:text-xs font-bold px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full"> <span className="bg-blue-100 text-blue-800 text-[10px] sm:text-xs font-bold px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full">
{reports.length} {reports.length}
</span> </span>
@@ -32,19 +40,20 @@ export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) =
<div <div
key={report.id} key={report.id}
onClick={() => onSelect(report)} onClick={() => onSelect(report)}
className={`rounded-lg p-3 sm:p-4 cursor-pointer relative hover:shadow-md transition-all ${ className={`rounded-lg p-3 sm:p-4 cursor-pointer hover:shadow-md transition-all ${
isSelected isSelected
? 'border-2 border-blue-500 bg-blue-50' ? 'border-2 border-blue-500 bg-blue-50'
: 'border border-gray-200 bg-white hover:border-blue-300' : 'border border-gray-200 bg-white hover:border-blue-300'
}`} }`}
> >
<div className="absolute top-3 sm:top-4 right-3 sm:right-4 text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded"> <div className="flex items-center justify-between gap-2 mb-1">
{report.quarter} <h3 className={`font-bold text-sm sm:text-lg ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
{report.code}
</h3>
<span className="text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded whitespace-nowrap">
{report.quarter}
</span>
</div> </div>
<h3 className={`font-bold text-sm sm:text-lg mb-1 ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
{report.code}
</h3>
<p className="text-xs sm:text-base text-gray-700 font-medium mb-1">{report.siteName}</p> <p className="text-xs sm:text-base text-gray-700 font-medium mb-1">{report.siteName}</p>
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3">: {report.item}</p> <p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3">: {report.item}</p>
@@ -52,8 +61,8 @@ export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) =
isSelected ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600' isSelected ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
}`}> }`}>
<Package size={16} /> <Package size={16} />
<span> {report.routeCount}</span> <span> {report.totalRoutes}</span>
<span className="text-gray-400 text-xs ml-1">( {report.totalRoutes})</span> <span className="text-gray-400 text-xs ml-1">( {report.routeCount}/{report.totalRoutes})</span>
</div> </div>
</div> </div>
); );

View File

@@ -11,9 +11,10 @@ interface RouteListProps {
onSelect: (route: RouteItem) => void; onSelect: (route: RouteItem) => void;
onToggleItem: (routeId: string, itemId: string, isCompleted: boolean) => void; onToggleItem: (routeId: string, itemId: string, isCompleted: boolean) => void;
reportCode: string | null; reportCode: string | null;
isMock?: boolean;
} }
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode }: RouteListProps) => { export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode, isMock }: RouteListProps) => {
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const handleClick = (route: RouteItem) => { const handleClick = (route: RouteItem) => {
@@ -28,17 +29,24 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
return ( return (
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden"> <div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4"> <div className="flex items-center gap-2 mb-3 sm:mb-4">
{' '} <h2 className="font-bold text-gray-800 text-xs sm:text-sm">
{reportCode && ( {' '}
<span className="text-gray-400 font-normal ml-1">({reportCode})</span> {reportCode && (
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
)}
</h2>
{isMock && (
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
Mock
</span>
)} )}
</h2> </div>
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1"> <div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
{routes.length === 0 ? ( {routes.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm"> <div className="flex items-center justify-center h-32 text-gray-400 text-sm">
{reportCode ? '수주트가 없습니다.' : '품질관리서를 선택해주세요.'} {reportCode ? '수주트가 없습니다.' : '품질관리서를 선택해주세요.'}
</div> </div>
) : ( ) : (
routes.map((route) => { routes.map((route) => {
@@ -72,8 +80,8 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
</span> </span>
)} )}
</div> </div>
<p className="text-xs text-gray-500 mb-1">: {route.date}</p> <p className="text-xs text-gray-500 mb-0.5">: {route.date || '-'}</p>
<p className="text-xs text-gray-500 mb-2">: {route.site}</p> <p className="text-xs text-gray-500 mb-2">: {route.site || '-'}{route.client ? ` (${route.client})` : ''}</p>
<div className="inline-flex items-center gap-1 bg-gray-100 px-2 py-0.5 rounded text-xs text-gray-600"> <div className="inline-flex items-center gap-1 bg-gray-100 px-2 py-0.5 rounded text-xs text-gray-600">
<MapPin size={10} /> <MapPin size={10} />
<span>{route.locationCount}</span> <span>{route.locationCount}</span>

View File

@@ -457,7 +457,7 @@ export const ImportInspectionDocument = forwardRef<ImportInspectionRef, ImportIn
}); });
// OK/NG 선택 핸들러 // OK/NG 선택 핸들러
const handleResultChange = useCallback((itemId: string, result: JudgmentResult) => { const _handleResultChange = useCallback((itemId: string, result: JudgmentResult) => {
if (readOnly) return; if (readOnly) return;
setValues((prev) => { setValues((prev) => {
@@ -773,8 +773,8 @@ export const ImportInspectionDocument = forwardRef<ImportInspectionRef, ImportIn
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{inspectionItems.map((item, idx) => { {inspectionItems.map((item, _idx) => {
const itemValue = values[item.id]; const _itemValue = values[item.id];
// 그룹핑 정보 // 그룹핑 정보
const hasCategory = !!item.subName; const hasCategory = !!item.subName;
@@ -903,13 +903,13 @@ export const ImportInspectionDocument = forwardRef<ImportInspectionRef, ImportIn
</td> </td>
) : item.measurementType === 'single_value' ? ( ) : item.measurementType === 'single_value' ? (
// 단일 입력 (colspan으로 합침) // 단일 입력 (colspan으로 합침) - 저장된 값이 있으면 표시
<td <td
className="border border-gray-400 px-2 py-1 text-center align-middle" className="border border-gray-400 px-2 py-1 text-center align-middle"
colSpan={3} colSpan={3}
rowSpan={isGroupItem ? itemRowSpan : 1} rowSpan={isGroupItem ? itemRowSpan : 1}
> >
<span className="text-gray-400 text-xs">( )</span> {renderMeasurementInput(item.id, 0)}
</td> </td>
) : item.measurementType === 'okng' ? ( ) : item.measurementType === 'okng' ? (
// OK/NG 선택형 - n 값에 따라 열 개수 결정 // OK/NG 선택형 - n 값에 따라 열 개수 결정

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import React, { useState, useRef, useCallback } from 'react'; import React, { useState, useRef, useCallback } from 'react';
import { Upload, FileText, Download, Trash2, Eye, RefreshCw, X } from 'lucide-react'; import { Upload, FileText, Download, Trash2, Eye, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
export interface QualityDocumentFile { export interface QualityDocumentFile {

View File

@@ -0,0 +1,236 @@
'use client';
import { useState, useCallback, useRef, useEffect } from 'react';
import { toast } from 'sonner';
import type { ChecklistCategory } from '../types';
import { getChecklistTemplate, saveChecklistTemplate } from '../actions';
function generateId() {
return `item-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function useChecklistTemplate() {
const [templateId, setTemplateId] = useState<number | null>(null);
const [editCategories, setEditCategories] = useState<ChecklistCategory[]>([]);
const savedRef = useRef<ChecklistCategory[]>([]);
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasChanges, setHasChanges] = useState(false);
// === 초기 로드 ===
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
getChecklistTemplate('day1_audit')
.then((result) => {
if (cancelled) return;
if (result.success && result.data) {
setTemplateId(result.data.id);
const cats = result.data.categories;
setEditCategories(structuredClone(cats));
savedRef.current = structuredClone(cats);
} else {
setError(result.error || '템플릿 로드 실패');
}
})
.catch(() => {
if (!cancelled) setError('템플릿 로드 중 오류 발생');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
// === 변경 추적 ===
const markChanged = useCallback(() => setHasChanges(true), []);
// === 카테고리 CRUD ===
const addCategory = useCallback(() => {
setEditCategories(prev => [
...prev,
{ id: generateId(), title: '새 카테고리', subItems: [] },
]);
markChanged();
}, [markChanged]);
const updateCategoryTitle = useCallback((categoryId: string, title: string) => {
setEditCategories(prev =>
prev.map(cat => cat.id === categoryId ? { ...cat, title } : cat)
);
markChanged();
}, [markChanged]);
const deleteCategory = useCallback((categoryId: string) => {
setEditCategories(prev => prev.filter(cat => cat.id !== categoryId));
markChanged();
}, [markChanged]);
// === 카테고리 순서 변경 ===
const moveCategoryUp = useCallback((index: number) => {
if (index <= 0) return;
setEditCategories(prev => {
const next = [...prev];
[next[index - 1], next[index]] = [next[index], next[index - 1]];
return next;
});
markChanged();
}, [markChanged]);
const moveCategoryDown = useCallback((index: number) => {
setEditCategories(prev => {
if (index >= prev.length - 1) return prev;
const next = [...prev];
[next[index], next[index + 1]] = [next[index + 1], next[index]];
return next;
});
markChanged();
}, [markChanged]);
// === 하위 항목 CRUD ===
const addSubItem = useCallback((categoryId: string) => {
setEditCategories(prev =>
prev.map(cat => {
if (cat.id !== categoryId) return cat;
return {
...cat,
subItems: [
...cat.subItems,
{ id: generateId(), name: '새 항목', isCompleted: false },
],
};
})
);
markChanged();
}, [markChanged]);
const updateSubItemName = useCallback((categoryId: string, subItemId: string, name: string) => {
setEditCategories(prev =>
prev.map(cat => {
if (cat.id !== categoryId) return cat;
return {
...cat,
subItems: cat.subItems.map(item =>
item.id === subItemId ? { ...item, name } : item
),
};
})
);
markChanged();
}, [markChanged]);
const deleteSubItem = useCallback((categoryId: string, subItemId: string) => {
setEditCategories(prev =>
prev.map(cat => {
if (cat.id !== categoryId) return cat;
return {
...cat,
subItems: cat.subItems.filter(item => item.id !== subItemId),
};
})
);
markChanged();
}, [markChanged]);
// === 하위 항목 순서 변경 ===
const moveSubItemUp = useCallback((categoryId: string, index: number) => {
if (index <= 0) return;
setEditCategories(prev =>
prev.map(cat => {
if (cat.id !== categoryId) return cat;
const items = [...cat.subItems];
[items[index - 1], items[index]] = [items[index], items[index - 1]];
return { ...cat, subItems: items };
})
);
markChanged();
}, [markChanged]);
const moveSubItemDown = useCallback((categoryId: string, index: number) => {
setEditCategories(prev =>
prev.map(cat => {
if (cat.id !== categoryId) return cat;
if (index >= cat.subItems.length - 1) return cat;
const items = [...cat.subItems];
[items[index], items[index + 1]] = [items[index + 1], items[index]];
return { ...cat, subItems: items };
})
);
markChanged();
}, [markChanged]);
// === 저장 ===
const saveTemplate = useCallback(async () => {
if (!templateId) return;
setSaving(true);
try {
// API용 데이터: isCompleted 제거
const apiCategories = editCategories.map(cat => ({
id: cat.id,
title: cat.title,
subItems: cat.subItems.map(item => ({
id: item.id,
name: item.name,
})),
}));
const result = await saveChecklistTemplate(templateId, {
categories: apiCategories,
});
if (result.success && result.data) {
const cats = result.data.categories;
setEditCategories(structuredClone(cats));
savedRef.current = structuredClone(cats);
setHasChanges(false);
toast.success('점검표가 저장되었습니다.');
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch {
toast.error('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
}, [editCategories, templateId]);
// === 초기화 ===
const resetToSaved = useCallback(() => {
setEditCategories(structuredClone(savedRef.current));
setHasChanges(false);
}, []);
return {
// 데이터
templateId,
editCategories,
hasChanges,
saving,
loading,
error,
// 카테고리
addCategory,
updateCategoryTitle,
deleteCategory,
moveCategoryUp,
moveCategoryDown,
// 하위 항목
addSubItem,
updateSubItemName,
deleteSubItem,
moveSubItemUp,
moveSubItemDown,
// 저장/초기화
saveTemplate,
resetToSaved,
};
}

View File

@@ -0,0 +1,147 @@
'use client';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { toast } from 'sonner';
import type { ChecklistCategory } from '../types';
import { getChecklistTemplate, toggleTemplateItem } from '../actions';
export function useDay1Audit() {
// 데이터 상태
const [templateId, setTemplateId] = useState<number | null>(null);
const [categories, setCategories] = useState<ChecklistCategory[]>([]);
// 선택 상태
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
// 로딩 상태
const [loadingChecklist, setLoadingChecklist] = useState(true);
const [pendingToggleIds, setPendingToggleIds] = useState<Set<string>>(new Set());
// 마운트 시 점검표 로드 (checklist_templates API)
useEffect(() => {
let cancelled = false;
setLoadingChecklist(true);
getChecklistTemplate('day1_audit')
.then((result) => {
if (cancelled) return;
if (result.success && result.data) {
setTemplateId(result.data.id);
setCategories(result.data.categories);
}
})
.finally(() => {
if (!cancelled) setLoadingChecklist(false);
});
return () => { cancelled = true; };
}, []);
// 진행률 계산
const day1Progress = useMemo(() => {
const total = categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
const completed = categories.reduce(
(sum, cat) => sum + cat.subItems.filter((item) => item.isCompleted).length,
0,
);
return { completed, total };
}, [categories]);
// 선택된 항목의 완료 여부
const isSelectedItemCompleted = useMemo(() => {
if (!selectedSubItemId) return false;
for (const cat of categories) {
const item = cat.subItems.find((sub) => sub.id === selectedSubItemId);
if (item) return item.isCompleted;
}
return false;
}, [categories, selectedSubItemId]);
// 선택된 점검 항목 정보 (중앙 패널용)
const selectedCheckItem = useMemo(() => {
if (!selectedSubItemId || !selectedCategoryId) return null;
const category = categories.find((c) => c.id === selectedCategoryId);
if (!category) return null;
const subItem = category.subItems.find((s) => s.id === selectedSubItemId);
if (!subItem) return null;
return {
id: `check-${subItem.id}`,
categoryId: category.id,
subItemId: subItem.id,
title: subItem.name,
description: '',
buttonLabel: '기준/매뉴얼 확인',
standardDocuments: [],
};
}, [selectedSubItemId, selectedCategoryId, categories]);
// === 핸들러 ===
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
setSelectedCategoryId(categoryId);
setSelectedSubItemId(subItemId);
}, []);
const handleSubItemToggle = useCallback(async (categoryId: string, subItemId: string) => {
if (!templateId || pendingToggleIds.has(subItemId)) return;
setPendingToggleIds((prev) => new Set(prev).add(subItemId));
try {
const result = await toggleTemplateItem(templateId, subItemId);
if (result.success && result.data) {
setCategories((prev) =>
prev.map((cat) => {
if (cat.id !== categoryId) return cat;
return {
...cat,
subItems: cat.subItems.map((item) => {
if (item.id !== subItemId) return item;
return { ...item, isCompleted: result.data!.isCompleted };
}),
};
}),
);
} else {
toast.error(result.error || '항목 상태 변경에 실패했습니다.');
}
} finally {
setPendingToggleIds((prev) => {
const next = new Set(prev);
next.delete(subItemId);
return next;
});
}
}, [templateId, pendingToggleIds]);
const handleConfirmComplete = useCallback(() => {
if (selectedCategoryId && selectedSubItemId) {
handleSubItemToggle(selectedCategoryId, selectedSubItemId);
}
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
return {
// 데이터
templateId,
categories,
day1Progress,
selectedCheckItem,
isSelectedItemCompleted,
// 선택
selectedSubItemId,
handleSubItemSelect,
// 토글
handleSubItemToggle,
handleConfirmComplete,
pendingToggleIds,
// 로딩
loadingChecklist,
// Mock 여부
isMock: false,
};
}

View File

@@ -0,0 +1,247 @@
'use client';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { toast } from 'sonner';
import type { InspectionReport, RouteItem, Document, DocumentItem } from '../types';
import {
getQualityReports,
getReportRoutes,
getRouteDocuments,
confirmUnitInspection,
} from '../actions';
const USE_MOCK = false;
export function useDay2LotAudit() {
// 필터 상태
const [selectedYear, setSelectedYear] = useState(2026);
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
const [searchTerm, setSearchTerm] = useState('');
// 데이터 상태
const [reports, setReports] = useState<InspectionReport[]>([]);
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>({});
const [documents, setDocuments] = useState<Document[]>([]);
// 선택 상태
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null);
// 로딩 상태
const [loadingReports, setLoadingReports] = useState(false);
const [loadingRoutes, setLoadingRoutes] = useState(false);
const [loadingDocuments, setLoadingDocuments] = useState(false);
const [pendingConfirmIds, setPendingConfirmIds] = useState<Set<string>>(new Set());
// 마운트 시 + 필터 변경 시 보고서 자동 로드
useEffect(() => {
if (USE_MOCK) return;
const loadReports = async () => {
setLoadingReports(true);
try {
const quarterNum = selectedQuarter !== '전체'
? parseInt(selectedQuarter.replace('Q', ''))
: undefined;
const result = await getQualityReports({
year: selectedYear,
quarter: quarterNum,
q: searchTerm || undefined,
});
if (result.success && result.data) {
setReports(result.data);
}
} finally {
setLoadingReports(false);
}
};
loadReports();
}, [selectedYear, selectedQuarter, searchTerm]);
// 진행률 계산
const day2Progress = useMemo(() => {
let completed = 0;
let total = 0;
Object.values(routesData).forEach((routes) => {
routes.forEach((route) => {
route.subItems.forEach((item) => {
total++;
if (item.isCompleted) completed++;
});
});
});
return { completed, total };
}, [routesData]);
// 필터링된 보고서 (API에서 이미 필터링되므로 그대로 반환)
const filteredReports = useMemo(() => {
return reports;
}, [reports]);
// 현재 루트/문서
const currentRoutes = useMemo(() => {
if (!selectedReport) return [];
return routesData[selectedReport.id] || [];
}, [selectedReport, routesData]);
const currentDocuments = useMemo(() => {
return documents;
}, [documents]);
// === API 호출 핸들러 ===
const handleReportSelect = useCallback(async (report: InspectionReport) => {
setSelectedReport(report);
setSelectedRoute(null);
setDocuments([]);
setLoadingRoutes(true);
try {
const result = await getReportRoutes(report.id);
if (result.success && result.data) {
setRoutesData((prev) => ({ ...prev, [report.id]: result.data! }));
}
} finally {
setLoadingRoutes(false);
}
}, []);
const handleRouteSelect = useCallback(async (route: RouteItem) => {
setSelectedRoute(route);
setLoadingDocuments(true);
try {
const result = await getRouteDocuments(route.id);
if (result.success && result.data) {
setDocuments(result.data);
}
} finally {
setLoadingDocuments(false);
}
}, []);
const handleViewDocument = useCallback((doc: Document, item?: DocumentItem) => {
// 품질관리서는 파일 다운로드로 처리 (DocumentList에서 직접 처리하므로 모달 열지 않음)
if (doc.type === 'quality') return;
setSelectedDoc(doc);
setSelectedDocItem(item || null);
setModalOpen(true);
}, []);
const handleToggleItem = useCallback(async (routeId: string, itemId: string, isCompleted: boolean) => {
// API: 비관적 업데이트
if (pendingConfirmIds.has(itemId)) return;
setPendingConfirmIds((prev) => new Set(prev).add(itemId));
try {
const result = await confirmUnitInspection(itemId, isCompleted);
if (result.success && result.data) {
setRoutesData((prev) => {
const newData = { ...prev };
for (const reportId of Object.keys(newData)) {
newData[reportId] = newData[reportId].map((route) => {
if (route.id !== routeId) return route;
return {
...route,
subItems: route.subItems.map((item) => {
if (item.id !== itemId) return item;
return { ...item, isCompleted: result.data!.isCompleted };
}),
};
});
}
return newData;
});
} else {
toast.error(result.error || '확인 상태 변경에 실패했습니다.');
}
} finally {
setPendingConfirmIds((prev) => {
const next = new Set(prev);
next.delete(itemId);
return next;
});
}
}, [pendingConfirmIds]);
// 품질관리서 파일 정보 업데이트 (업로드 성공 후 documents 상태 반영)
// 품질관리서는 doc.id가 'quality' 문자열이므로 type으로 매칭
const updateQualityDocumentFile = useCallback((_docId: string, fileInfo: { fileId: number; fileName: string; fileSize: number }) => {
setDocuments((prev) =>
prev.map((doc) =>
doc.type === 'quality'
? { ...doc, fileId: fileInfo.fileId, fileName: fileInfo.fileName, fileSize: fileInfo.fileSize }
: doc
)
);
}, []);
const handleYearChange = useCallback((year: number) => {
setSelectedYear(year);
setSelectedReport(null);
setSelectedRoute(null);
setDocuments([]);
}, []);
const handleQuarterChange = useCallback((quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
setSelectedQuarter(quarter);
setSelectedReport(null);
setSelectedRoute(null);
setDocuments([]);
}, []);
const handleSearchChange = useCallback((term: string) => {
setSearchTerm(term);
}, []);
return {
// 필터
selectedYear,
selectedQuarter,
searchTerm,
handleYearChange,
handleQuarterChange,
handleSearchChange,
// 데이터
filteredReports,
currentRoutes,
currentDocuments,
day2Progress,
// 선택
selectedReport,
selectedRoute,
handleReportSelect,
handleRouteSelect,
// 모달
modalOpen,
selectedDoc,
selectedDocItem,
handleViewDocument,
setModalOpen,
// 토글
handleToggleItem,
pendingConfirmIds,
// 품질관리서 파일
updateQualityDocumentFile,
// 로딩
loadingReports,
loadingRoutes,
loadingDocuments,
// Mock 여부
isMock: USE_MOCK,
};
}

View File

@@ -6,6 +6,7 @@ import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/ty
export const MOCK_WORK_ORDER: WorkOrder = { export const MOCK_WORK_ORDER: WorkOrder = {
id: 'wo-1', id: 'wo-1',
orderNo: 'KD-WO-240924-01', orderNo: 'KD-WO-240924-01',
productCode: 'WY-SC780',
productName: '스크린 셔터 (표준형)', productName: '스크린 셔터 (표준형)',
processCode: 'screen', processCode: 'screen',
processName: 'screen', processName: 'screen',
@@ -97,13 +98,14 @@ export const MOCK_REPORTS: InspectionReport[] = [
}, },
]; ];
// 수주트 목록 (reportId로 연결) // 수주트 목록 (reportId로 연결)
export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = { export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
'1': [ '1': [
{ {
id: '1-1', id: '1-1',
code: 'KD-SS-240924-19', code: 'KD-SS-240924-19',
date: '2024-09-24', date: '2024-09-24',
client: '(주)강남건설',
site: '강남 아파트 A동', site: '강남 아파트 A동',
locationCount: 7, locationCount: 7,
subItems: [ subItems: [
@@ -120,6 +122,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '1-2', id: '1-2',
code: 'KD-SS-241024-15', code: 'KD-SS-241024-15',
date: '2024-10-24', date: '2024-10-24',
client: '(주)강남건설',
site: '강남 아파트 B동', site: '강남 아파트 B동',
locationCount: 7, locationCount: 7,
subItems: [ subItems: [
@@ -133,6 +136,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '2-1', id: '2-1',
code: 'SC-AP-241101-01', code: 'SC-AP-241101-01',
date: '2024-11-01', date: '2024-11-01',
client: '서초개발(주)',
site: '서초 오피스텔 본관', site: '서초 오피스텔 본관',
locationCount: 8, locationCount: 8,
subItems: [ subItems: [
@@ -146,6 +150,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '3-1', id: '3-1',
code: 'SP-CW-240801-01', code: 'SP-CW-240801-01',
date: '2024-08-01', date: '2024-08-01',
client: '송파건설(주)',
site: '송파 주상복합 A타워', site: '송파 주상복합 A타워',
locationCount: 10, locationCount: 10,
subItems: [ subItems: [
@@ -156,6 +161,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '3-2', id: '3-2',
code: 'SP-CW-240815-02', code: 'SP-CW-240815-02',
date: '2024-08-15', date: '2024-08-15',
client: '송파건설(주)',
site: '송파 주상복합 B타워', site: '송파 주상복합 B타워',
locationCount: 8, locationCount: 8,
subItems: [], subItems: [],
@@ -164,6 +170,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '3-3', id: '3-3',
code: 'SP-CW-240901-03', code: 'SP-CW-240901-03',
date: '2024-09-01', date: '2024-09-01',
client: '송파건설(주)',
site: '송파 주상복합 상가동', site: '송파 주상복합 상가동',
locationCount: 3, locationCount: 3,
subItems: [], subItems: [],

View File

@@ -1,28 +1,27 @@
"use client"; "use client";
import React, { useState, useMemo, useCallback } from 'react'; import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import { Header } from './components/Header'; import { Header } from './components/Header';
import { Filters } from './components/Filters'; import { Filters } from './components/Filters';
import { ReportList } from './components/ReportList'; import { ReportList } from './components/ReportList';
import { RouteList } from './components/RouteList'; import { RouteList } from './components/RouteList';
import { DocumentList } from './components/DocumentList'; import { DocumentList } from './components/DocumentList';
// import { InspectionModal } from './components/InspectionModal';
import { InspectionModal } from './components/InspectionModal'; import { InspectionModal } from './components/InspectionModal';
import { InspectionReportModal } from '@/components/production/WorkOrders/documents';
import { WorkLogModal } from '@/components/production/WorkOrders/documents';
import { ProductInspectionViewModal } from '@/components/quality/InspectionManagement/ProductInspectionViewModal';
import { getDocumentDetail } from './actions';
import { DayTabs } from './components/DayTabs'; import { DayTabs } from './components/DayTabs';
import { Day1ChecklistPanel } from './components/Day1ChecklistPanel'; import { Day1ChecklistPanel } from './components/Day1ChecklistPanel';
import { Day1DocumentSection } from './components/Day1DocumentSection'; import { Day1DocumentSection } from './components/Day1DocumentSection';
import { Day1DocumentViewer } from './components/Day1DocumentViewer'; import { Day1DocumentViewer } from './components/Day1DocumentViewer';
import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel'; import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel';
import { InspectionReport, RouteItem, Document, DocumentItem, ChecklistCategory } from './types'; import { useDay1Audit } from './hooks/useDay1Audit';
import { import { useDay2LotAudit } from './hooks/useDay2LotAudit';
MOCK_REPORTS, import { useChecklistTemplate } from './hooks/useChecklistTemplate';
MOCK_ROUTES_INITIAL, import { uploadTemplateDocument, getTemplateDocuments, deleteTemplateDocument, uploadQualityDocumentFile } from './actions';
MOCK_DOCUMENTS, import type { TemplateDocument } from './types';
DEFAULT_DOCUMENTS,
MOCK_DAY1_CATEGORIES,
MOCK_DAY1_CHECK_ITEMS,
MOCK_DAY1_STANDARD_DOCUMENTS,
} from './mockData';
// 기본 설정값 // 기본 설정값
const DEFAULT_SETTINGS: AuditDisplaySettings = { const DEFAULT_SETTINGS: AuditDisplaySettings = {
@@ -41,195 +40,127 @@ export default function QualityInspectionPage() {
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [displaySettings, setDisplaySettings] = useState<AuditDisplaySettings>(DEFAULT_SETTINGS); const [displaySettings, setDisplaySettings] = useState<AuditDisplaySettings>(DEFAULT_SETTINGS);
// 1일차 상태 // 1일차 커스텀 훅
const [day1Categories, setDay1Categories] = useState<ChecklistCategory[]>(MOCK_DAY1_CATEGORIES); const {
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null); templateId,
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null); categories,
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null); day1Progress,
selectedCheckItem,
isSelectedItemCompleted,
selectedSubItemId,
handleSubItemSelect,
handleSubItemToggle,
handleConfirmComplete,
isMock: day1IsMock,
} = useDay1Audit();
// 2일차(로트추적) 필터 상태 // 점검표 템플릿 관리 훅 (설정 모달용)
const [selectedYear, setSelectedYear] = useState(2025); const checklistTemplate = useChecklistTemplate();
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
const [searchTerm, setSearchTerm] = useState('');
// 2일차 선택 상태 // 업로드된 파일 상태 (subItemId별)
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null); const [uploadedFiles, setUploadedFiles] = useState<Record<string, TemplateDocument[]>>({});
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null); const [selectedUploadedFile, setSelectedUploadedFile] = useState<TemplateDocument | null>(null);
// 2일차 루트 데이터 상태 (완료 토글용) // 선택된 항목 변경 시 파일 목록 로드 + 업로드 파일 선택 초기화
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(MOCK_ROUTES_INITIAL); useEffect(() => {
setSelectedUploadedFile(null);
if (!selectedSubItemId || !templateId) return;
if (uploadedFiles[selectedSubItemId]) return; // 이미 로드됨
// 모달 상태 getTemplateDocuments(templateId, selectedSubItemId).then((result) => {
const [modalOpen, setModalOpen] = useState(false); if (result.success && result.data) {
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null); setUploadedFiles((prev) => ({ ...prev, [selectedSubItemId]: result.data! }));
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null); }
// ===== 1일차 진행률 계산 =====
const day1Progress = useMemo(() => {
const total = day1Categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
const completed = day1Categories.reduce(
(sum, cat) => sum + cat.subItems.filter(item => item.isCompleted).length,
0
);
return { completed, total };
}, [day1Categories]);
// ===== 2일차 진행률 계산 (개소별 완료 기준) =====
const day2Progress = useMemo(() => {
let completed = 0;
let total = 0;
Object.values(routesData).forEach(routes => {
routes.forEach(route => {
route.subItems.forEach(item => {
total++;
if (item.isCompleted) completed++;
});
});
}); });
return { completed, total }; }, [selectedSubItemId, templateId]);
}, [routesData]);
// ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) ===== // 파일 업로드 핸들러
const handleFileUpload = useCallback(async (subItemId: string, file: File): Promise<boolean> => {
if (!templateId) {
toast.error('점검표 템플릿이 로드되지 않았습니다.');
return false;
}
const result = await uploadTemplateDocument(templateId, subItemId, file);
if (!result.success) {
toast.error(result.error || '파일 업로드에 실패했습니다.');
return false;
}
// 업로드 성공 시 파일 목록에 추가
if (result.data) {
setUploadedFiles((prev) => ({
...prev,
[subItemId]: [result.data!, ...(prev[subItemId] || [])],
}));
}
return true;
}, [templateId]);
// 파일 삭제 핸들러
const handleFileDelete = useCallback(async (fileId: number, subItemId: string) => {
const result = await deleteTemplateDocument(fileId);
if (result.success) {
setUploadedFiles((prev) => ({
...prev,
[subItemId]: (prev[subItemId] || []).filter((f) => f.id !== fileId),
}));
toast.success('파일이 삭제되었습니다.');
} else {
toast.error(result.error || '파일 삭제에 실패했습니다.');
}
}, []);
// 2일차 커스텀 훅
const {
selectedYear,
selectedQuarter,
searchTerm,
handleYearChange,
handleQuarterChange,
handleSearchChange,
filteredReports,
currentRoutes,
currentDocuments,
day2Progress,
selectedReport,
selectedRoute,
handleReportSelect,
handleRouteSelect,
modalOpen,
selectedDoc,
selectedDocItem,
handleViewDocument,
setModalOpen,
handleToggleItem,
updateQualityDocumentFile,
isMock: day2IsMock,
} = useDay2LotAudit();
// 품질관리서 파일 업로드 핸들러 (2일차 DocumentList용)
const handleQualityFileUpload = useCallback(async (qualityDocumentId: string, file: File): Promise<boolean> => {
const result = await uploadQualityDocumentFile(qualityDocumentId, file);
if (!result.success) {
toast.error(result.error || '품질관리서 파일 업로드에 실패했습니다.');
return false;
}
// 업로드 성공 시 documents 상태에서 해당 문서의 파일 정보 업데이트
if (result.data) {
updateQualityDocumentFile(qualityDocumentId, result.data);
}
return true;
}, [updateQualityDocumentFile]);
// 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션)
const filteredDay1Categories = useMemo(() => { const filteredDay1Categories = useMemo(() => {
if (displaySettings.showCompletedItems) return day1Categories; if (displaySettings.showCompletedItems) return categories;
return day1Categories.map(category => ({ return categories.map(category => ({
...category, ...category,
subItems: category.subItems.filter(item => !item.isCompleted), subItems: category.subItems.filter(item => !item.isCompleted),
})).filter(category => category.subItems.length > 0); })).filter(category => category.subItems.length > 0);
}, [day1Categories, displaySettings.showCompletedItems]); }, [categories, displaySettings.showCompletedItems]);
// ===== 1일차 핸들러 =====
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
setSelectedCategoryId(categoryId);
setSelectedSubItemId(subItemId);
setSelectedStandardDocId(null);
}, []);
const handleSubItemToggle = useCallback((categoryId: string, subItemId: string, isCompleted: boolean) => {
setDay1Categories(prev => prev.map(cat => {
if (cat.id !== categoryId) return cat;
return {
...cat,
subItems: cat.subItems.map(item => {
if (item.id !== subItemId) return item;
return { ...item, isCompleted };
}),
};
}));
}, []);
const handleConfirmComplete = useCallback(() => {
if (selectedCategoryId && selectedSubItemId) {
handleSubItemToggle(selectedCategoryId, selectedSubItemId, true);
}
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
// 선택된 1일차 점검 항목
const selectedCheckItem = useMemo(() => {
if (!selectedSubItemId) return null;
return MOCK_DAY1_CHECK_ITEMS.find(item => item.subItemId === selectedSubItemId) || null;
}, [selectedSubItemId]);
// 선택된 표준 문서
const selectedStandardDoc = useMemo(() => {
if (!selectedStandardDocId || !selectedSubItemId) return null;
const docs = MOCK_DAY1_STANDARD_DOCUMENTS[selectedSubItemId] || [];
return docs.find(doc => doc.id === selectedStandardDocId) || null;
}, [selectedStandardDocId, selectedSubItemId]);
// 선택된 항목의 완료 여부
const isSelectedItemCompleted = useMemo(() => {
if (!selectedSubItemId) return false;
for (const cat of day1Categories) {
const item = cat.subItems.find(item => item.id === selectedSubItemId);
if (item) return item.isCompleted;
}
return false;
}, [day1Categories, selectedSubItemId]);
// ===== 2일차(로트추적) 로직 =====
const filteredReports = useMemo(() => {
return MOCK_REPORTS.filter((report) => {
if (report.year !== selectedYear) return false;
if (selectedQuarter !== '전체') {
const quarterNum = parseInt(selectedQuarter.replace('Q', ''));
if (report.quarterNum !== quarterNum) return false;
}
if (searchTerm) {
const term = searchTerm.toLowerCase();
const matchesCode = report.code.toLowerCase().includes(term);
const matchesSite = report.siteName.toLowerCase().includes(term);
const matchesItem = report.item.toLowerCase().includes(term);
if (!matchesCode && !matchesSite && !matchesItem) return false;
}
return true;
});
}, [selectedYear, selectedQuarter, searchTerm]);
const currentRoutes = useMemo(() => {
if (!selectedReport) return [];
return routesData[selectedReport.id] || [];
}, [selectedReport, routesData]);
const currentDocuments = useMemo(() => {
if (!selectedRoute) return DEFAULT_DOCUMENTS;
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
}, [selectedRoute]);
const handleReportSelect = (report: InspectionReport) => {
setSelectedReport(report);
setSelectedRoute(null);
};
const handleRouteSelect = (route: RouteItem) => {
setSelectedRoute(route);
};
const handleViewDocument = (doc: Document, item?: DocumentItem) => {
setSelectedDoc(doc);
setSelectedDocItem(item || null);
setModalOpen(true);
};
const handleYearChange = (year: number) => {
setSelectedYear(year);
setSelectedReport(null);
setSelectedRoute(null);
};
const handleQuarterChange = (quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
setSelectedQuarter(quarter);
setSelectedReport(null);
setSelectedRoute(null);
};
const handleSearchChange = (term: string) => {
setSearchTerm(term);
};
// ===== 2일차 개소별 완료 토글 =====
const handleToggleItem = useCallback((routeId: string, itemId: string, isCompleted: boolean) => {
setRoutesData(prev => {
const newData = { ...prev };
for (const reportId of Object.keys(newData)) {
newData[reportId] = newData[reportId].map(route => {
if (route.id !== routeId) return route;
return {
...route,
subItems: route.subItems.map(item => {
if (item.id !== itemId) return item;
return { ...item, isCompleted };
}),
};
});
}
return newData;
});
}, []);
return ( return (
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-hidden"> <div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-auto">
{/* 헤더 (설정 버튼 포함) */} {/* 헤더 (설정 버튼 포함) */}
<Header <Header
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />} rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
@@ -283,9 +214,9 @@ export default function QualityInspectionPage() {
{activeDay === 1 ? ( {activeDay === 1 ? (
// ===== 기준/매뉴얼 심사 심사 ===== // ===== 기준/매뉴얼 심사 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0"> <div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-[500px]">
{/* 좌측: 점검표 항목 */} {/* 좌측: 점검표 항목 */}
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${ <div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentSection && displaySettings.showDocumentViewer displaySettings.showDocumentSection && displaySettings.showDocumentViewer
? 'lg:col-span-3' ? 'lg:col-span-3'
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer : displaySettings.showDocumentSection || displaySettings.showDocumentViewer
@@ -298,59 +229,70 @@ export default function QualityInspectionPage() {
searchTerm={searchTerm} searchTerm={searchTerm}
onSubItemSelect={handleSubItemSelect} onSubItemSelect={handleSubItemSelect}
onSubItemToggle={handleSubItemToggle} onSubItemToggle={handleSubItemToggle}
isMock={day1IsMock}
/> />
</div> </div>
{/* 중앙: 기준 문서화 */} {/* 중앙: 기준 문서화 */}
{displaySettings.showDocumentSection && ( {displaySettings.showDocumentSection && (
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${ <div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8' displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
}`}> }`}>
<Day1DocumentSection <Day1DocumentSection
checkItem={selectedCheckItem} checkItem={selectedCheckItem}
selectedDocumentId={selectedStandardDocId}
onDocumentSelect={setSelectedStandardDocId}
onConfirmComplete={handleConfirmComplete} onConfirmComplete={handleConfirmComplete}
isCompleted={isSelectedItemCompleted} isCompleted={isSelectedItemCompleted}
isMock={day1IsMock}
onFileUpload={handleFileUpload}
uploadedFiles={selectedSubItemId ? uploadedFiles[selectedSubItemId] || [] : []}
onFileDelete={selectedSubItemId ? (fileId) => handleFileDelete(fileId, selectedSubItemId) : undefined}
onFileSelect={(file) => {
setSelectedUploadedFile(file);
}}
selectedFileId={selectedUploadedFile?.id ?? null}
/> />
</div> </div>
)} )}
{/* 우측: 문서 뷰어 */} {/* 우측: 문서 뷰어 */}
{displaySettings.showDocumentViewer && ( {displaySettings.showDocumentViewer && (
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${ <div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8' displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
}`}> }`}>
<Day1DocumentViewer document={selectedStandardDoc} /> <Day1DocumentViewer document={null} uploadedFile={selectedUploadedFile} isMock={day1IsMock} />
</div> </div>
)} )}
</div> </div>
) : ( ) : (
// ===== 로트 추적 심사 심사 ===== // ===== 로트 추적 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0"> <div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-[500px]">
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden"> <div className="col-span-12 lg:col-span-4 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto">
<ReportList <ReportList
reports={filteredReports} reports={filteredReports}
selectedId={selectedReport?.id || null} selectedId={selectedReport?.id || null}
onSelect={handleReportSelect} onSelect={handleReportSelect}
isMock={day2IsMock}
/> />
</div> </div>
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden"> <div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
<RouteList <RouteList
routes={currentRoutes} routes={currentRoutes}
selectedId={selectedRoute?.id || null} selectedId={selectedRoute?.id || null}
onSelect={handleRouteSelect} onSelect={handleRouteSelect}
onToggleItem={handleToggleItem} onToggleItem={handleToggleItem}
reportCode={selectedReport?.code || null} reportCode={selectedReport?.code || null}
isMock={day2IsMock}
/> />
</div> </div>
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden"> <div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
<DocumentList <DocumentList
documents={currentDocuments} documents={currentDocuments}
routeCode={selectedRoute?.code || null} routeCode={selectedRoute?.code || null}
onViewDocument={handleViewDocument} onViewDocument={handleViewDocument}
onQualityFileUpload={handleQualityFileUpload}
isMock={day2IsMock}
/> />
</div> </div>
</div> </div>
@@ -362,15 +304,72 @@ export default function QualityInspectionPage() {
onClose={() => setSettingsOpen(false)} onClose={() => setSettingsOpen(false)}
settings={displaySettings} settings={displaySettings}
onSettingsChange={setDisplaySettings} onSettingsChange={setDisplaySettings}
checklistManagement={{
categories: checklistTemplate.editCategories,
hasChanges: checklistTemplate.hasChanges,
saving: checklistTemplate.saving,
loading: checklistTemplate.loading,
error: checklistTemplate.error,
onAddCategory: checklistTemplate.addCategory,
onUpdateCategoryTitle: checklistTemplate.updateCategoryTitle,
onDeleteCategory: checklistTemplate.deleteCategory,
onMoveCategoryUp: checklistTemplate.moveCategoryUp,
onMoveCategoryDown: checklistTemplate.moveCategoryDown,
onAddSubItem: checklistTemplate.addSubItem,
onUpdateSubItemName: checklistTemplate.updateSubItemName,
onDeleteSubItem: checklistTemplate.deleteSubItem,
onMoveSubItemUp: checklistTemplate.moveSubItemUp,
onMoveSubItemDown: checklistTemplate.moveSubItemDown,
onSave: checklistTemplate.saveTemplate,
onReset: checklistTemplate.resetToSaved,
}}
/> />
<InspectionModal {/* 중간검사 성적서 → 기존 독립 모달 재사용 */}
isOpen={modalOpen} {selectedDoc?.type === 'report' && (
onClose={() => setModalOpen(false)} <InspectionReportModal
document={selectedDoc} open={modalOpen}
documentItem={selectedDocItem} onOpenChange={(open) => !open && setModalOpen(false)}
readOnly workOrderId={selectedDocItem?.workOrderId ? String(selectedDocItem.workOrderId) : selectedDocItem?.id || null}
/> processType={
selectedDocItem?.subType === 'jointbar' ? 'slat'
: (selectedDocItem?.subType as 'screen' | 'slat' | 'bending') || 'screen'
}
readOnly
isJointBar={selectedDocItem?.subType === 'jointbar'}
/>
)}
{/* 작업일지 → 독립 WorkLogModal */}
{selectedDoc?.type === 'log' && (
<WorkLogModal
open={modalOpen}
onOpenChange={(open) => !open && setModalOpen(false)}
workOrderId={selectedDocItem?.workOrderId ? String(selectedDocItem.workOrderId) : selectedDocItem?.id || null}
processType={(selectedDocItem?.subType as 'screen' | 'slat' | 'bending') || 'screen'}
/>
)}
{/* 제품검사 성적서 → 독립 ProductInspectionViewModal */}
{selectedDoc?.type === 'product' && (
<ProductInspectionViewModal
open={modalOpen}
onOpenChange={(open) => !open && setModalOpen(false)}
locationId={selectedDocItem?.id || null}
fetchDetail={getDocumentDetail}
/>
)}
{/* 나머지 문서 타입 (수입검사, 수주서, 납품확인서, 출고증) → 기존 InspectionModal */}
{selectedDoc && !['report', 'log', 'product', 'quality'].includes(selectedDoc.type) && (
<InspectionModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
document={selectedDoc}
documentItem={selectedDocItem}
readOnly
/>
)}
</div> </div>
); );
} }

View File

@@ -14,6 +14,7 @@ export interface RouteItem {
id: string; id: string;
code: string; // e.g., KD-SS-240924-19 code: string; // e.g., KD-SS-240924-19
date: string; // 2024-09-24 date: string; // 2024-09-24
client: string; // 거래처(발주처)
site: string; // 강남 아파트 A동 site: string; // 강남 아파트 A동
locationCount: number; locationCount: number;
subItems: UnitInspection[]; subItems: UnitInspection[];
@@ -33,6 +34,9 @@ export interface Document {
date?: string; date?: string;
count: number; // e.g., 3건의 서류 count: number; // e.g., 3건의 서류
items?: DocumentItem[]; items?: DocumentItem[];
fileId?: number; // files.id (품질관리서 파일)
fileName?: string; // 파일명
fileSize?: number; // 파일 크기 (bytes)
} }
export interface DocumentItem { export interface DocumentItem {
@@ -42,6 +46,8 @@ export interface DocumentItem {
code?: string; code?: string;
// 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용) // 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용)
subType?: 'screen' | 'bending' | 'slat' | 'jointbar'; subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
// 작업일지/중간검사에서 실제 WorkOrder 데이터 로딩용
workOrderId?: number;
} }
// ===== 기준/매뉴얼 심사 심사 타입 ===== // ===== 기준/매뉴얼 심사 심사 타입 =====
@@ -92,3 +98,28 @@ export interface Day2Progress {
completed: number; completed: number;
total: number; total: number;
} }
// 업로드된 템플릿 문서
export interface TemplateDocument {
id: number;
fieldKey: string;
displayName: string;
fileSize: number;
mimeType: string;
uploadedBy?: string | null;
createdAt?: string | null;
}
// ===== 점검표 템플릿 관리 타입 =====
// 점검표 템플릿 (API 응답)
export interface ChecklistTemplate {
id: number;
name: string;
type: string;
categories: ChecklistCategory[];
options: Record<string, unknown> | null;
fileCounts: Record<string, number>;
updatedAt: string | null;
updatedBy: string | null;
}

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