41 Commits

Author SHA1 Message Date
유병철
bec933b3b4 refactor: CEO 대시보드 mockData/modalConfigs 정리 및 BillManagement 간소화
- mockData 불필요 데이터 대폭 제거
- modalConfigs (cardManagement, entertainment, monthlyExpense, vat, welfare) 정리
- CEODashboard 컴포넌트 개선
- BillManagementClient 간소화
2026-03-05 21:22:17 +09:00
유병철
1675f3edcf feat: 어음관리 리팩토링 및 CEO 대시보드 SummaryNavBar 추가
- BillManagement: BillDetail 리팩토링, sections/hooks 분리, constants 추가
- BillManagement types 대폭 확장, actions 개선
- GiftCertificateManagement: actions/types 확장
- CEO 대시보드: SummaryNavBar 컴포넌트 추가, useSectionSummary 훅
- bill-prototype 개발 페이지 업데이트
2026-03-05 20:47:43 +09:00
유병철
2fe47c86d3 refactor: VehicleDispatch actions 불필요 코드 제거 2026-03-05 13:38:16 +09:00
유병철
00a6209347 feat: 레이아웃/출하/생산/회계/대시보드 전반 개선
- HeaderFavoritesBar 대폭 개선
- Sidebar/AuthenticatedLayout 소폭 수정
- ShipmentCreate, VehicleDispatch 출하 관련 개선
- WorkOrderCreate/Edit, WorkerScreen 생산 관련 개선
- InspectionCreate 자재 입고검사 개선
- DailyReport, VendorDetail 회계 수정
- CEO 대시보드: CardManagement/DailyProduction/DailyAttendance 섹션 개선
- useCEODashboard, expense transformer 정비
- DocumentViewer, PDF generate route 소폭 수정
- bill-prototype 개발 페이지 추가
- mockData 불필요 데이터 제거
2026-03-05 13:35:48 +09:00
c18c68b6b7 chore: [infra] Slack 알림 채널 분리 — product_infra → deploy_react 2026-03-05 11:32:17 +09:00
03d129c32c fix: [outbound] 출하관리 캘린더 기본 뷰 week-time으로 변경 2026-03-05 11:01:27 +09:00
d6e3131c6a fix: [production] 절곡 중간검사 데이터 새로고침 시 초기화 버그 수정
- InspectionInputModal: 이전 형식 데이터(products 배열 없음) 로드 시 judgment 기반 제품별 상태 추론
- InspectionInputModal: skipAutoJudgmentRef로 이전 형식 로드 시 auto-judgment 덮어쓰기 방지
- BendingInspectionContent: products/bendingStatus 없을 때 judgment 기반 fallback 추가
2026-03-05 10:39:45 +09:00
1d3805781c feat: [outbound] 배차차량관리 목업→API 연동 전환
- mockData 제거, executePaginatedAction/executeServerAction 사용
- buildApiUrl로 쿼리 파라미터 빌드
- API 응답(snake_case) → 프론트 타입(camelCase) 변환 함수 추가
2026-03-04 23:36:20 +09:00
b45c35a5e8 fix: [production] 절곡 중간검사 수주 단위 데이터 공유 모델 적용
- 로드 경로: 절곡 공정 시 어떤 item이든 inspection_data 있으면 모든 개소에 공유
- 저장 경로: 절곡 검사 완료 시 inspectionDataMap에 모든 workItem 동기화
- TemplateInspectionContent: products 배열 우선 복원 (EAV 문서 데이터보다 우선)
- workOrderId prop 추가 (절곡 gap_points API 동적 로딩)
2026-03-04 23:27:12 +09:00
b05e19e9f8 fix: [quality] QMS mockData에 productCode 필드 누락 수정
WorkOrder 타입 필수 필드 productCode 추가하여 빌드 오류 해결
2026-03-04 22:40:23 +09:00
4331b84a63 feat: [production] 절곡 중간검사 입력 모달 — 7개 제품 항목 통합 및 성적서 데이터 연동
- InspectionInputModal: 절곡 전용 7개 제품별 입력 폼 (절곡상태/길이/너비/간격)
- TemplateInspectionContent: products 배열 → bending cellValues 자동 매핑
- 제품 ID 3단계 매칭 (정규화→키워드→인덱스 폴백)
- 절곡 작업지시서 bending 섹션 개선
2026-03-04 22:28:16 +09:00
0b81e9c1dd feat: [process] 공정 단계에 검사범위(InspectionScope) 설정 추가
- 전수검사/샘플링/그룹 유형 선택 UI
- 샘플링 시 샘플 크기(n) 입력
- options JSON으로 API 저장/복원
2026-03-04 22:28:16 +09:00
f653960a30 fix: [shipment] 배차 상세/수정 기본정보 그리드 레이아웃 개선 (1열→2x4열) 2026-03-04 22:28:16 +09:00
888fae119f chore: next dev에서 --turbo 플래그 제거 2026-03-04 22:28:16 +09:00
f503e20030 fix: [production] 작업자 화면 하드코딩 도면 이미지 영역 제거
- BendingExtraInfo, WipExtraInfo에서 drawingUrl 도면 이미지 div 제거
- types.ts에서 drawingUrl 필드 제거
- actions.ts, index.tsx에서 drawing_url 매핑 제거
2026-03-04 22:28:16 +09:00
0166601be8 fix: [production] 자재투입 모달 — 동일 자재 다중 BOM 그룹 LOT 독립 관리
- getLotKey에 groupKey 포함하여 그룹별 LOT 선택/배정 독립 처리
- physicalUsed 맵으로 물리LOT 교차그룹 가용량 추적
- handleAutoFill FIFO 자동입력 (교차그룹 가용량 고려)
- handleSubmit 그룹별 개별 엔트리 전송 (bom_group_key 포함, replace 모드)
- 기투입 LOT 자동 선택 및 배지 표시, 수량 수동 편집 input
- allGroupsFulfilled 조건으로 투입 버튼 활성화 제어
- actions.ts: lotInputtedQty 필드 + bom_group_key/replace 파라미터 추가
2026-03-04 22:28:16 +09:00
83a23701a7 feat: [shipment] 배차정보 다중 행 API 연동 — actions.ts transform 함수 수정
- ShipmentApiData에 vehicle_dispatches 타입 추가
- transformApiToDetail: vehicle_dispatches 배열 매핑 (레거시 단일필드 fallback 유지)
- transformCreateFormToApi/transformEditFormToApi: vehicleDispatches → vehicle_dispatches 변환 추가
- transformApiToListItem: 첫 번째 배차의 arrival_datetime 반영
2026-03-04 22:28:16 +09:00
bedfd1f559 fix: [production] 작업자 화면 제품명 표시 간소화 — productCode만 표시
작업목록, 상세카드, 자재투입, 중간검사 모달에서 부품 목록까지 길게
표시되던 제품명을 productCode만 표시하도록 변경
2026-03-04 22:28:16 +09:00
8bcabafd08 fix: [production] 자재투입 모달 — bomGroupKey 그룹핑, 카테고리 순서 정렬, 번호 표시
- bomGroupKey 기반 그룹핑 (같은 item_id라도 category+partType별 분리)
- 카테고리 순서 정렬 (가이드레일→하단마감재→셔터박스→연기차단재)
- 카테고리 내 원형번호(①②③) 표시
- partType 배지 추가
- MaterialForItemInput에 bomGroupKey 필드 추가
2026-03-04 22:28:16 +09:00
5ff5093d7b fix: [출고관리] 목록 테이블 수신자/수신주소/수신처/작성자/출고일 API 매핑 연동
- OrderInfoApiData에 writer_name, writer_id, delivery_date 필드 추가
- transformApiToListItem에서 5개 필드 매핑 누락 수정
2026-03-04 22:28:16 +09:00
유병철
23fa9c0ea2 feat: CEO 대시보드 접대비/복리후생비/매출채권/캘린더 섹션 개선 및 회계 페이지 확장
- 접대비/복리후생비 섹션: 리스크감지형 구조로 변경
- 매출채권 섹션: transformer/타입 정비
- 캘린더 섹션: ScheduleDetailModal 개선
- 카드관리 모달 transformer 확장
- useCEODashboard 훅 리팩토링 및 정리
- dashboard endpoints/types/transformers (expense, receivable, tax-benefits) 대폭 확장
- 회계 5개 페이지(은행거래, 카드거래, 매출채권, 세금계산서, 거래처원장) 기능 개선
- ApprovalBox 소폭 수정
- CLAUDE.md 업데이트
2026-03-04 22:19:10 +09:00
유병철
cde9333652 feat: CEO 대시보드 API 연동 강화 및 회계/결재/HR 개선
- CEO 대시보드: 예상비용, 현황이슈, 일별매출/매입 등 모달 API 연동 확대
- dashboard transformers 리팩토링 (hr, sales-purchase, production-logistics 분리)
- useCEODashboard 훅 대폭 확장 (모달 데이터 fetching 로직)
- DailyReport: USD 섹션 추가 및 레이아웃 개선
- VendorManagement/ApprovalBox: 소폭 개선
- VacationManagement: 소폭 수정
- component-registry previews 업데이트
- claudedocs: 대시보드 API 스펙, 분석 문서 추가
2026-03-03 22:18:48 +09:00
유병철
7bb8699403 Merge branch 'develop' of http://114.203.209.83:3000/SamProject/sam-react-prod into develop 2026-03-01 12:17:47 +09:00
유병철
1bccaffe27 feat: CEO 대시보드 리팩토링 및 회계 관리 개선
- CEO 대시보드: 컴포넌트 분리(DashboardSettingsSections, DetailModalSections), 모달/섹션 개선, useCEODashboard 최적화
- 회계: 부실채권/매출/매입/일일보고 UI 및 타입 개선
- 공통: Sidebar, useDashboardFetch 훅 추가, amount/status-config 유틸 개선

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

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

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

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

View File

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

128
Jenkinsfile vendored
View File

@@ -1,12 +1,6 @@
pipeline {
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 {
disableConcurrentBuilds()
}
@@ -14,66 +8,10 @@ pipeline {
environment {
DEPLOY_USER = 'hskwon'
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
PROD_SERVER = '211.117.60.189'
}
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') {
when { expression { params.ACTION == 'deploy' } }
steps {
checkout scm
script {
@@ -85,7 +23,6 @@ pipeline {
}
stage('Prepare Env') {
when { expression { params.ACTION == 'deploy' } }
steps {
script {
if (env.BRANCH_NAME == 'main') {
@@ -100,23 +37,16 @@ pipeline {
}
stage('Install') {
when { expression { params.ACTION == 'deploy' } }
steps { sh 'npm install --prefer-offline' }
}
stage('Build') {
when { expression { params.ACTION == 'deploy' } }
steps { sh 'npm run build' }
}
// ── develop → 개발서버 배포 ──
stage('Deploy Development') {
when {
allOf {
branch 'develop'
expression { params.ACTION == 'deploy' }
}
}
when { branch 'develop' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
@@ -133,21 +63,16 @@ pipeline {
// ── main → 운영서버 Stage 배포 ──
stage('Deploy Stage') {
when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
rsync -az --delete \
.next package.json next.config.ts public node_modules \
${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/
scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production
ssh ${DEPLOY_USER}@${PROD_SERVER} '
${DEPLOY_USER}@211.117.60.189:/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
ssh ${DEPLOY_USER}@211.117.60.189 '
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/react-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
@@ -172,12 +97,7 @@ pipeline {
// ── main → Production 재빌드 (운영 환경변수) ──
stage('Rebuild for Production') {
when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
when { branch 'main' }
steps {
sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production"
sh 'npm run build'
@@ -186,21 +106,16 @@ pipeline {
// ── main → 운영서버 Production 배포 ──
stage('Deploy Production') {
when {
allOf {
branch 'main'
expression { params.ACTION == 'deploy' }
}
}
when { branch 'main' }
steps {
sshagent(credentials: ['deploy-ssh-key']) {
sh """
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
rsync -az --delete \
.next package.json next.config.ts public node_modules \
${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/
scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/.env.production
ssh ${DEPLOY_USER}@${PROD_SERVER} '
${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/
scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.production
ssh ${DEPLOY_USER}@211.117.60.189 '
ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
cd /home/webservice && pm2 reload sam-front &&
cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
@@ -213,23 +128,12 @@ pipeline {
post {
success {
script {
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}>"
}
}
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 {
script {
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}>"
}
}
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}

View File

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

View File

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

View File

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

BIN
claudedocs/architecture/.DS_Store vendored Normal file

Binary file not shown.

View File

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

View File

@@ -15,7 +15,6 @@ const eslintConfig = [
"node_modules/**",
"next-env.d.ts",
"src/components/_unused/**", // Archived unused components
"src/components/settings/AccountManagement/_legacy/**", // Legacy files
"src/hooks/useCurrentTime.ts", // Demo hook
],
},
@@ -77,38 +76,9 @@ const eslintConfig = [
HTMLTableCaptionElement: "readonly",
HTMLTextAreaElement: "readonly",
HTMLCanvasElement: "readonly",
HTMLDivElement: "readonly",
HTMLElement: "readonly",
HTMLImageElement: "readonly",
ImageData: "readonly",
Image: "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: {
@@ -125,9 +95,7 @@ const eslintConfig = [
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-unused-vars": ["error", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_"
"varsIgnorePattern": "^_"
}],
},
},

View File

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

View File

@@ -3,7 +3,7 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "node scripts/validate-next-cache.mjs && NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' next dev",
"dev": "next dev",
"build": "next build",
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
"start": "next start -H 0.0.0.0",

View File

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

@@ -1,49 +0,0 @@
/**
* .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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,17 +11,23 @@
'use client';
import { useSearchParams, useRouter } from 'next/navigation';
import { useState, useMemo, Suspense } from 'react';
import { FileText, ArrowLeft, Calendar, Clock, MapPin, FileCheck } from 'lucide-react';
import { useState, useEffect, useMemo, Suspense } from 'react';
import { FileText, ArrowLeft, Calendar, User, Clock, MapPin, FileCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { FormSectionSkeleton } from '@/components/ui/skeleton';
import { format } from 'date-fns';
import { ko } from 'date-fns/locale';
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';
export default function EmployeeCSVUploadPage() {
const handleUpload = (_employees: Employee[]) => {
const handleUpload = (employees: Employee[]) => {
// TODO: API 연동
};

View File

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

View File

@@ -1,307 +0,0 @@
'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;
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,
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: '파일 삭제에 실패했습니다.',
});
}

View File

@@ -1,11 +1,9 @@
'use client';
import React, { useState } from 'react';
import { Settings, X, Eye, EyeOff, ListChecks } from 'lucide-react';
import { Switch } from '@/components/ui/switch';
import React from 'react';
import { Settings, X, Eye, EyeOff } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ChecklistTemplateEditor } from './ChecklistTemplateEditor';
import type { ChecklistCategory } from '../types';
import { Switch } from '@/components/ui/switch';
export interface AuditDisplaySettings {
showProgressBar: boolean;
@@ -15,46 +13,19 @@ export interface AuditDisplaySettings {
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 {
isOpen: boolean;
onClose: () => void;
settings: AuditDisplaySettings;
onSettingsChange: (settings: AuditDisplaySettings) => void;
checklistManagement?: ChecklistManagementProps;
}
type TabType = 'display' | 'checklist';
export function AuditSettingsPanel({
isOpen,
onClose,
settings,
onSettingsChange,
checklistManagement,
}: AuditSettingsPanelProps) {
const [activeTab, setActiveTab] = useState<TabType>('display');
const handleToggle = (key: keyof AuditDisplaySettings) => {
onSettingsChange({
...settings,
@@ -78,7 +49,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 gap-2">
<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>
<button
type="button"
@@ -89,183 +60,103 @@ export function AuditSettingsPanel({
</button>
</div>
{/* */}
<div className="flex border-b border-gray-200">
<button
type="button"
onClick={() => setActiveTab('display')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
activeTab === 'display'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
)}
>
<Eye className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => setActiveTab('checklist')}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
activeTab === 'checklist'
? 'text-blue-600 border-b-2 border-blue-600'
: 'text-gray-500 hover:text-gray-700'
)}
>
<ListChecks className="h-3.5 w-3.5" />
</button>
</div>
{/* 탭 컨텐츠 */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'display' ? (
<DisplaySettingsContent
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 className="flex-1 overflow-y-auto p-4 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={() => handleToggle('showProgressBar')}
/>
<SettingRow
label="문서 뷰어"
description="우측 문서 미리보기 패널을 표시합니다"
checked={settings.showDocumentViewer}
onChange={() => handleToggle('showDocumentViewer')}
/>
<SettingRow
label="기준 문서화 섹션"
description="중앙 기준 문서 목록 패널을 표시합니다"
checked={settings.showDocumentSection}
onChange={() => handleToggle('showDocumentSection')}
/>
</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>
);
}
// ===== 화면 설정 탭 컨텐츠 (기존 코드 분리) =====
{/* 구분선 */}
<div className="border-t border-gray-200" />
interface DisplaySettingsContentProps {
settings: AuditDisplaySettings;
onToggle: (key: keyof AuditDisplaySettings) => void;
onSettingsChange: (settings: AuditDisplaySettings) => void;
}
{/* 점검표 섹션 */}
<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>
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 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="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 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 SettingRowProps {
label: string;
description: string;
@@ -312,4 +203,4 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
<span> </span>
</button>
);
}
}

View File

@@ -1,449 +0,0 @@
'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,7 +11,6 @@ interface Day1ChecklistPanelProps {
searchTerm: string;
onSubItemSelect: (categoryId: string, subItemId: string) => void;
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
isMock?: boolean;
}
export function Day1ChecklistPanel({
@@ -20,7 +19,6 @@ export function Day1ChecklistPanel({
searchTerm,
onSubItemSelect,
onSubItemToggle,
isMock,
}: Day1ChecklistPanelProps) {
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
@@ -50,13 +48,6 @@ export function Day1ChecklistPanel({
}).filter((cat): cat is ChecklistCategory => cat !== null);
}, [categories, searchTerm]);
// categories 로드 완료 시 모두 펼치기
React.useEffect(() => {
if (categories.length > 0) {
setExpandedCategories(new Set(categories.map(c => c.id)));
}
}, [categories]);
// 검색 시 모든 카테고리 펼치기
React.useEffect(() => {
if (searchTerm.trim()) {
@@ -104,14 +95,7 @@ export function Day1ChecklistPanel({
<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="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>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
{/* 검색 결과 카운트 */}
{searchTerm && (
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
@@ -130,7 +114,7 @@ export function Day1ChecklistPanel({
</div>
) : (
filteredCategories.map((category, _categoryIndex) => {
filteredCategories.map((category, categoryIndex) => {
const isExpanded = expandedCategories.has(category.id);
const progress = getCategoryProgress(category);
const allCompleted = progress.completed === progress.total;

View File

@@ -1,45 +1,25 @@
'use client';
import React, { useState, useRef, useCallback } from 'react';
import { FileText, CheckCircle2, Upload, X, Loader2, Trash2 } from 'lucide-react';
import React from 'react';
import { FileText, Download, Eye, CheckCircle2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
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;
import type { Day1CheckItem, StandardDocument } from '../types';
interface Day1DocumentSectionProps {
checkItem: Day1CheckItem | null;
selectedDocumentId: string | null;
onDocumentSelect: (documentId: string) => void;
onConfirmComplete: () => void;
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({
checkItem,
selectedDocumentId,
onDocumentSelect,
onConfirmComplete,
isCompleted,
isMock,
onFileUpload,
uploadedFiles = [],
onFileDelete,
onFileSelect,
selectedFileId,
}: Day1DocumentSectionProps) {
if (!checkItem) {
return (
@@ -56,14 +36,7 @@ export function Day1DocumentSection({
<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="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>
<h3 className="font-semibold text-gray-900 text-sm sm:text-base"> </h3>
</div>
{/* 콘텐츠 */}
@@ -74,23 +47,19 @@ export function Day1DocumentSection({
<p className="text-xs sm:text-sm text-blue-700">{checkItem.description}</p>
</div>
{/* 관련 기준 문서 */}
{/* 기준 문서 목록 */}
<div>
<h5 className="text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2"> </h5>
<div className="space-y-1">
{uploadedFiles.map((file) => (
<UploadedFileRow
key={file.id}
file={file}
isSelected={selectedFileId === file.id}
onSelect={onFileSelect ? () => onFileSelect(file) : undefined}
onDelete={onFileDelete ? () => onFileDelete(file.id) : undefined}
<div className="space-y-1.5 sm:space-y-2">
{checkItem.standardDocuments.map((doc) => (
<DocumentRow
key={doc.id}
document={doc}
isSelected={selectedDocumentId === doc.id}
onSelect={() => onDocumentSelect(doc.id)}
/>
))}
</div>
<DocumentUploadArea
onUpload={onFileUpload ? (file) => onFileUpload(checkItem.subItemId, file) : undefined}
/>
</div>
{/* 확인 버튼 */}
@@ -122,200 +91,67 @@ export function Day1DocumentSection({
);
}
// ===== 파일 업로드 영역 =====
interface DocumentUploadAreaProps {
onUpload?: (file: File) => Promise<boolean>;
interface DocumentRowProps {
document: StandardDocument;
isSelected: boolean;
onSelect: () => void;
}
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);
function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) {
return (
<div
className={cn(
'flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-colors',
'flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border cursor-pointer transition-colors',
isSelected
? 'bg-blue-50 border-blue-300'
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
)}
onClick={onSelect}
>
{/* 아이콘 */}
<div className={cn(
'flex-shrink-0 w-7 h-7 rounded flex items-center justify-center',
ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
'flex-shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center',
document.fileName?.endsWith('.pdf')
? 'bg-red-100 text-red-600'
: 'bg-green-100 text-green-600'
)}>
<FileText className="h-3.5 w-3.5" />
<FileText className="h-4 w-4 sm:h-5 sm:w-5" />
</div>
{/* 문서 정보 */}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-gray-800 truncate">{file.displayName}</p>
<p className="text-[10px] text-gray-400">{sizeMB} MB</p>
<p className="text-sm font-medium text-gray-900 truncate">{document.title}</p>
<p className="text-xs text-gray-500">
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
<span>{document.date}</span>
</p>
</div>
{onDelete && (
{/* 액션 버튼 */}
<div className="flex items-center gap-1">
<button
type="button"
onClick={(e) => { e.stopPropagation(); onDelete(); }}
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
title="삭제"
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
title="미리보기"
onClick={(e) => {
e.stopPropagation();
onSelect();
}}
>
<Trash2 className="h-3.5 w-3.5" />
<Eye className="h-4 w-4" />
</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>
);
}

View File

@@ -3,120 +3,13 @@
import React from 'react';
import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { StandardDocument, TemplateDocument } from '../types';
import type { StandardDocument } from '../types';
interface Day1DocumentViewerProps {
document: StandardDocument | null;
uploadedFile?: TemplateDocument | null;
isMock?: boolean;
}
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>
);
}
export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
if (!document) {
return (
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
@@ -145,14 +38,7 @@ export function Day1DocumentViewer({ document, uploadedFile, isMock }: Day1Docum
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
</div>
<div>
<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>
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
<p className="text-[10px] sm:text-xs text-gray-500">
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
{document.date}

View File

@@ -11,7 +11,6 @@ interface DocumentListProps {
documents: Document[];
routeCode: string | null;
onViewDocument: (doc: Document, item?: DocumentItem) => void;
isMock?: boolean;
}
const getIcon = (type: string) => {
@@ -28,7 +27,7 @@ const getIcon = (type: string) => {
}
};
export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: DocumentListProps) => {
export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentListProps) => {
const [expandedId, setExpandedId] = useState<string | null>(null);
// 문서 카테고리 클릭 핸들러
@@ -53,24 +52,17 @@ export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: D
return (
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
<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 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
{' '}
{routeCode && (
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
)}
</div>
</h2>
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
{!routeCode ? (
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
.
.
</div>
) : (
documents.map((doc) => {
@@ -122,7 +114,7 @@ export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: D
{item.code && (
<>
<span className="mx-1">|</span>
{item.code}
: {item.code}
</>
)}
</div>

View File

@@ -6,7 +6,6 @@ import { DocumentViewer } from '@/components/document-system';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { Document, DocumentItem } from '../types';
import { getDocumentDetail } from '../actions';
import { MOCK_SHIPMENT_DETAIL } from '../mockData';
// 기존 문서 컴포넌트 import
@@ -25,12 +24,10 @@ import {
QualityDocumentUploader,
} from './documents';
// 제품검사 성적서 (FQC 양식) import
import { FqcDocumentContent } from '@/components/quality/InspectionManagement/documents/FqcDocumentContent';
// 제품검사 성적서 (신규 양식) import
import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument';
import type { FqcTemplate, FqcDocumentData } from '@/components/quality/InspectionManagement/fqcActions';
import type { InspectionReportDocument as InspectionReportDocumentType, ProductInspectionData } from '@/components/quality/InspectionManagement/types';
import { mockReportInspectionItems, mapInspectionDataToItems } from '@/components/quality/InspectionManagement/mockData';
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 (공정별 신규 버전)
@@ -47,9 +44,6 @@ import type { WorkOrder } from '@/components/production/WorkOrders/types';
// 검사 템플릿 API
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
// 작업지시 상세 API (QMS 작업일지/중간검사용)
import { getWorkOrderById } from '@/components/production/WorkOrders/actions';
/**
* 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환
*
@@ -142,7 +136,7 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D
)}
{docItem?.code && (
<p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4">
: {docItem.code}
: {docItem.code}
</p>
)}
<div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
@@ -166,32 +160,26 @@ const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [
{ 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 },
];
// FQC 문서 API 응답 → FqcTemplate 변환
function transformFqcApiToTemplate(apiTemplate: Record<string, unknown>): FqcTemplate {
const t = apiTemplate as {
id: number; name: string; category: string; title: string | null;
approval_lines: { id: number; name: string; department: string; sort_order: number }[];
basic_fields: { id: number; label: string; field_key: string; field_type: string; default_value: string | null; is_required: boolean; sort_order: number }[];
sections: { id: number; name: string; title: string | null; description: string | null; image_path: string | null; sort_order: number;
items: { id: number; section_id: number; item_name: string; standard: string | null; tolerance: string | null; measurement_type: string; frequency: string; sort_order: number; category: string; method: string }[];
}[];
columns: { id: number; label: string; column_type: string; width: string | null; group_name: string | null; sort_order: number }[];
};
return {
id: t.id, name: t.name, category: t.category, title: t.title,
approvalLines: (t.approval_lines || []).map(a => ({ id: a.id, name: a.name, department: a.department, sortOrder: a.sort_order })),
basicFields: (t.basic_fields || []).map(f => ({ id: f.id, label: f.label, fieldKey: f.field_key, fieldType: f.field_type, defaultValue: f.default_value, isRequired: f.is_required, sortOrder: f.sort_order })),
sections: (t.sections || []).map(s => ({
id: s.id, name: s.name, title: s.title, description: s.description, imagePath: s.image_path, sortOrder: s.sort_order,
items: (s.items || []).map(i => ({ id: i.id, sectionId: i.section_id, itemName: i.item_name, standard: i.standard, tolerance: i.tolerance, measurementType: i.measurement_type, frequency: i.frequency, sortOrder: i.sort_order, category: i.category || '', method: i.method || '' })),
})),
columns: (t.columns || []).map(c => ({ id: c.id, label: c.label, columnType: c.column_type, width: c.width, groupName: c.group_name ?? null, sortOrder: c.sort_order })),
};
}
function transformFqcApiToData(apiData: { section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }[]): FqcDocumentData[] {
return (apiData || []).map(d => ({ sectionId: d.section_id, columnId: d.column_id, rowIndex: d.row_index, fieldKey: d.field_key, fieldValue: d.field_value }));
}
// QMS용 제품검사 성적서 Mock 데이터
const QMS_MOCK_REPORT_DATA: InspectionReportDocumentType = {
documentNumber: 'RPT-KD-SS-2024-530',
createdDate: '2024-09-24',
approvalLine: [
{ role: '작성', name: '김검사', department: '품질관리부' },
{ role: '승인', name: '박승인', department: '품질관리부' },
],
productName: '방화스크린',
productLotNo: 'KD-SS-240924-19',
productCode: 'WY-SC780',
lotSize: '8',
client: '삼성물산(주)',
inspectionDate: '2024-09-26',
siteName: '강남 아파트 단지',
inspector: '김검사',
inspectionItems: mockReportInspectionItems,
specialNotes: '',
finalJudgment: '합격',
};
// QMS용 작업일지 Mock WorkOrder 생성
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({
@@ -282,24 +270,10 @@ export const InspectionModal = ({
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false);
const [templateError, setTemplateError] = useState<string | null>(null);
// 작업일지/중간검사용 WorkOrder 상태
const [workOrderData, setWorkOrderData] = useState<WorkOrder | null>(null);
const [isLoadingWorkOrder, setIsLoadingWorkOrder] = useState(false);
const [workOrderError, setWorkOrderError] = useState<string | null>(null);
// 수입검사 저장용 ref/상태
const importDocRef = useRef<ImportInspectionRef>(null);
const [isSaving, setIsSaving] = useState(false);
// 제품검사 성적서 FQC 상태
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
const [fqcData, setFqcData] = useState<FqcDocumentData[]>([]);
const [fqcDocumentNo, setFqcDocumentNo] = useState<string>('');
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
const [fqcError, setFqcError] = useState<string | null>(null);
// 레거시 inspection_data 기반 제품검사 성적서
const [legacyReportData, setLegacyReportData] = useState<InspectionReportDocumentType | null>(null);
// 수입검사 템플릿 로드 (모달 열릴 때)
useEffect(() => {
// itemId가 있으면 실제 API로 조회, 없으면 itemName/specification으로 mock 조회
@@ -311,53 +285,9 @@ export const InspectionModal = ({
setImportTemplate(null);
setImportInitialValues(undefined);
setTemplateError(null);
setFqcTemplate(null);
setFqcData([]);
setFqcDocumentNo('');
setFqcError(null);
setLegacyReportData(null);
setWorkOrderData(null);
setWorkOrderError(null);
}
}, [isOpen, doc?.type, itemId, itemName, specification]);
// 작업일지/중간검사 WorkOrder 로드 (모달 열릴 때)
// log: documentItem.id === work_order_id, report: documentItem.workOrderId로 전달
useEffect(() => {
if (isOpen && (doc?.type === 'log' || doc?.type === 'report')) {
const woId = documentItem?.workOrderId || (doc?.type === 'log' ? Number(documentItem?.id) : null);
if (woId) {
loadWorkOrderData(woId);
}
}
}, [isOpen, doc?.type, documentItem?.workOrderId, documentItem?.id]);
// 제품검사 성적서 FQC 로드 (모달 열릴 때)
useEffect(() => {
if (isOpen && doc?.type === 'product' && documentItem?.id) {
loadFqcDocument(documentItem.id);
}
}, [isOpen, doc?.type, documentItem?.id]);
const loadWorkOrderData = async (workOrderId: number) => {
setIsLoadingWorkOrder(true);
setWorkOrderError(null);
try {
const result = await getWorkOrderById(String(workOrderId));
if (result.success && result.data) {
setWorkOrderData(result.data);
} else {
setWorkOrderError(result.error || '작업지시 데이터를 불러올 수 없습니다.');
}
} catch (error) {
console.error('[InspectionModal] loadWorkOrderData error:', error);
setWorkOrderError('작업지시 데이터 로드 중 오류가 발생했습니다.');
} finally {
setIsLoadingWorkOrder(false);
}
};
const loadInspectionTemplate = async () => {
// itemId가 있으면 실제 API 호출, 없으면 itemName/specification 필요
if (!itemId && (!itemName || !specification)) return;
@@ -400,78 +330,11 @@ export const InspectionModal = ({
}
};
// 제품검사 성적서 문서 로드 (FQC 우선, inspection_data fallback)
const loadFqcDocument = async (locationId: string) => {
setIsLoadingFqc(true);
setFqcError(null);
setLegacyReportData(null);
try {
const result = await getDocumentDetail('product', locationId);
if (result.success && result.data) {
const data = result.data as {
document_id: number | null;
inspection_status: string | null;
inspection_data: ProductInspectionData | null;
floor_code: string | null;
symbol_code: string | null;
fqc_document?: {
document_no: string;
template: Record<string, unknown>;
data: { section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }[];
};
};
if (data.fqc_document) {
// FQC 문서가 있는 경우
setFqcTemplate(transformFqcApiToTemplate(data.fqc_document.template));
setFqcData(transformFqcApiToData(data.fqc_document.data));
setFqcDocumentNo(data.fqc_document.document_no || '');
} else if (data.inspection_data && data.inspection_status === 'completed') {
// FQC 없지만 inspection_data가 있는 경우 → 레거시 리포트 생성
const inspData = data.inspection_data;
const mappedItems = mapInspectionDataToItems(mockReportInspectionItems, inspData);
const locationLabel = [data.floor_code, data.symbol_code].filter(Boolean).join(' ');
setLegacyReportData({
documentNumber: '',
createdDate: '',
approvalLine: [
{ role: '작성', name: '', department: '' },
{ role: '승인', name: '', department: '' },
],
productName: inspData.productName || '',
productLotNo: '',
productCode: '',
lotSize: '1',
client: '',
inspectionDate: '',
siteName: locationLabel,
inspector: '',
productImages: inspData.productImages || [],
inspectionItems: mappedItems,
specialNotes: inspData.specialNotes || '',
finalJudgment: '합격',
});
} else {
setFqcError('제품검사 성적서 문서가 아직 생성되지 않았습니다.');
}
} else {
setFqcError(result.error || '제품검사 성적서 조회에 실패했습니다.');
}
} catch (error) {
console.error('[InspectionModal] loadFqcDocument error:', error);
setFqcError('제품검사 성적서 로드 중 오류가 발생했습니다.');
} finally {
setIsLoadingFqc(false);
}
};
// 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함)
const handleImportSave = useCallback(async () => {
if (!importDocRef.current) return;
const _data = importDocRef.current.getInspectionData();
const data = importDocRef.current.getInspectionData();
setIsSaving(true);
try {
// TODO: 실제 저장 API 연동
@@ -487,73 +350,49 @@ export const InspectionModal = ({
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
const subtitle = documentItem
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` ${documentItem.code}` : ''}`
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` 로트: ${documentItem.code}` : ''}`
: docInfo.label;
// 품질관리서 PDF 업로드 핸들러
const handleQualityFileUpload = (_file: File) => {
const handleQualityFileUpload = (file: File) => {
};
const handleQualityFileDelete = () => {
};
// 작업일지/중간검사 공통: WorkOrder 데이터 로딩 상태 처리
const renderWorkOrderLoading = () => {
if (isLoadingWorkOrder) {
return (
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
<p className="text-gray-600 text-sm"> ...</p>
</div>
);
}
if (workOrderError) {
return <ErrorDocument message={workOrderError} onRetry={documentItem?.workOrderId ? () => loadWorkOrderData(documentItem.workOrderId!) : undefined} />;
}
return null;
};
// 작업일지 공정별 렌더링
const renderWorkLogDocument = () => {
const loadingEl = renderWorkOrderLoading();
if (loadingEl) return loadingEl;
const subType = documentItem?.subType;
// 실제 WorkOrder 데이터 사용, 없으면 fallback mock
const orderData = workOrderData || createQmsMockWorkOrder(subType);
const mockOrder = createQmsMockWorkOrder(subType);
switch (subType) {
case 'screen':
return <ScreenWorkLogContent data={orderData} />;
return <ScreenWorkLogContent data={mockOrder} />;
case 'slat':
return <SlatWorkLogContent data={orderData} />;
return <SlatWorkLogContent data={mockOrder} />;
case 'bending':
return <BendingWorkLogContent data={orderData} />;
return <BendingWorkLogContent data={mockOrder} />;
default:
return <ScreenWorkLogContent data={orderData} />;
// subType 미지정 시 스크린 기본
return <ScreenWorkLogContent data={mockOrder} />;
}
};
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
const renderReportDocument = () => {
const loadingEl = renderWorkOrderLoading();
if (loadingEl) return loadingEl;
const subType = documentItem?.subType;
// 실제 WorkOrder 데이터 사용, 없으면 fallback mock
const orderData = workOrderData || createQmsMockWorkOrder(subType || 'screen');
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
switch (subType) {
case 'screen':
return <ScreenInspectionContent data={orderData} readOnly />;
return <ScreenInspectionContent data={mockOrder} readOnly />;
case 'bending':
return <BendingInspectionContent data={orderData} readOnly />;
return <BendingInspectionContent data={mockOrder} readOnly />;
case 'slat':
return <SlatInspectionContent data={orderData} readOnly />;
return <SlatInspectionContent data={mockOrder} readOnly />;
case 'jointbar':
return <JointbarInspectionDocument />;
default:
return <ScreenInspectionContent data={orderData} readOnly />;
return <ScreenInspectionContent data={mockOrder} readOnly />;
}
};
@@ -579,36 +418,6 @@ export const InspectionModal = ({
);
};
// 제품검사 성적서 렌더링 (FQC 우선, inspection_data fallback)
const renderProductDocument = () => {
if (isLoadingFqc) {
return <LoadingDocument />;
}
if (fqcError) {
return <ErrorDocument message={fqcError} onRetry={documentItem?.id ? () => loadFqcDocument(documentItem.id) : undefined} />;
}
// FQC 문서 기반 렌더링
if (fqcTemplate) {
return (
<FqcDocumentContent
template={fqcTemplate}
documentData={fqcData}
documentNo={fqcDocumentNo}
readonly
/>
);
}
// 레거시 inspection_data 기반 렌더링
if (legacyReportData) {
return <InspectionReportDocument data={legacyReportData} />;
}
return <PlaceholderDocument docType="product" docItem={documentItem} />;
};
// 문서 타입에 따른 컨텐츠 렌더링
const renderDocumentContent = () => {
switch (doc.type) {
@@ -644,7 +453,7 @@ export const InspectionModal = ({
case 'import':
return renderImportInspectionDocument();
case 'product':
return renderProductDocument();
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
case 'report':
return renderReportDocument();
case 'quality':

View File

@@ -8,21 +8,13 @@ interface ReportListProps {
reports: InspectionReport[];
selectedId: string | null;
onSelect: (report: InspectionReport) => void;
isMock?: boolean;
}
export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportListProps) => {
export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) => {
return (
<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 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>
<h2 className="font-bold text-sm sm:text-lg text-gray-800"> </h2>
<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}
</span>
@@ -40,20 +32,19 @@ export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportList
<div
key={report.id}
onClick={() => onSelect(report)}
className={`rounded-lg p-3 sm:p-4 cursor-pointer hover:shadow-md transition-all ${
className={`rounded-lg p-3 sm:p-4 cursor-pointer relative hover:shadow-md transition-all ${
isSelected
? 'border-2 border-blue-500 bg-blue-50'
: 'border border-gray-200 bg-white hover:border-blue-300'
}`}
>
<div className="flex items-center justify-between gap-2 mb-1">
<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 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">
{report.quarter}
</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-sm text-gray-500 mb-2 sm:mb-3">: {report.item}</p>
@@ -61,7 +52,7 @@ export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportList
isSelected ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
}`}>
<Package size={16} />
<span> {report.routeCount}</span>
<span> {report.routeCount}</span>
<span className="text-gray-400 text-xs ml-1">( {report.totalRoutes})</span>
</div>
</div>

View File

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

View File

@@ -457,7 +457,7 @@ export const ImportInspectionDocument = forwardRef<ImportInspectionRef, ImportIn
});
// OK/NG 선택 핸들러
const _handleResultChange = useCallback((itemId: string, result: JudgmentResult) => {
const handleResultChange = useCallback((itemId: string, result: JudgmentResult) => {
if (readOnly) return;
setValues((prev) => {
@@ -773,8 +773,8 @@ export const ImportInspectionDocument = forwardRef<ImportInspectionRef, ImportIn
</tr>
</thead>
<tbody>
{inspectionItems.map((item, _idx) => {
const _itemValue = values[item.id];
{inspectionItems.map((item, idx) => {
const itemValue = values[item.id];
// 그룹핑 정보
const hasCategory = !!item.subName;

View File

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

View File

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

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

@@ -1,229 +0,0 @@
'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) => {
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]);
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,
// 로딩
loadingReports,
loadingRoutes,
loadingDocuments,
// Mock 여부
isMock: USE_MOCK,
};
}

View File

@@ -98,14 +98,13 @@ export const MOCK_REPORTS: InspectionReport[] = [
},
];
// 수주트 목록 (reportId로 연결)
// 수주트 목록 (reportId로 연결)
export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
'1': [
{
id: '1-1',
code: 'KD-SS-240924-19',
date: '2024-09-24',
client: '(주)강남건설',
site: '강남 아파트 A동',
locationCount: 7,
subItems: [
@@ -122,7 +121,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '1-2',
code: 'KD-SS-241024-15',
date: '2024-10-24',
client: '(주)강남건설',
site: '강남 아파트 B동',
locationCount: 7,
subItems: [
@@ -136,7 +134,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '2-1',
code: 'SC-AP-241101-01',
date: '2024-11-01',
client: '서초개발(주)',
site: '서초 오피스텔 본관',
locationCount: 8,
subItems: [
@@ -150,7 +147,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '3-1',
code: 'SP-CW-240801-01',
date: '2024-08-01',
client: '송파건설(주)',
site: '송파 주상복합 A타워',
locationCount: 10,
subItems: [
@@ -161,7 +157,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '3-2',
code: 'SP-CW-240815-02',
date: '2024-08-15',
client: '송파건설(주)',
site: '송파 주상복합 B타워',
locationCount: 8,
subItems: [],
@@ -170,7 +165,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
id: '3-3',
code: 'SP-CW-240901-03',
date: '2024-09-01',
client: '송파건설(주)',
site: '송파 주상복합 상가동',
locationCount: 3,
subItems: [],

View File

@@ -1,23 +1,28 @@
"use client";
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { toast } from 'sonner';
import React, { useState, useMemo, useCallback } from 'react';
import { Header } from './components/Header';
import { Filters } from './components/Filters';
import { ReportList } from './components/ReportList';
import { RouteList } from './components/RouteList';
import { DocumentList } from './components/DocumentList';
// import { InspectionModal } from './components/InspectionModal';
import { InspectionModal } from './components/InspectionModal';
import { DayTabs } from './components/DayTabs';
import { Day1ChecklistPanel } from './components/Day1ChecklistPanel';
import { Day1DocumentSection } from './components/Day1DocumentSection';
import { Day1DocumentViewer } from './components/Day1DocumentViewer';
import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel';
import { useDay1Audit } from './hooks/useDay1Audit';
import { useDay2LotAudit } from './hooks/useDay2LotAudit';
import { useChecklistTemplate } from './hooks/useChecklistTemplate';
import { uploadTemplateDocument, getTemplateDocuments, deleteTemplateDocument } from './actions';
import type { TemplateDocument } from './types';
import { InspectionReport, RouteItem, Document, DocumentItem, ChecklistCategory } from './types';
import {
MOCK_REPORTS,
MOCK_ROUTES_INITIAL,
MOCK_DOCUMENTS,
DEFAULT_DOCUMENTS,
MOCK_DAY1_CATEGORIES,
MOCK_DAY1_CHECK_ITEMS,
MOCK_DAY1_STANDARD_DOCUMENTS,
} from './mockData';
// 기본 설정값
const DEFAULT_SETTINGS: AuditDisplaySettings = {
@@ -36,112 +41,195 @@ export default function QualityInspectionPage() {
const [settingsOpen, setSettingsOpen] = useState(false);
const [displaySettings, setDisplaySettings] = useState<AuditDisplaySettings>(DEFAULT_SETTINGS);
// 1일차 커스텀 훅
const {
templateId,
categories,
day1Progress,
selectedCheckItem,
isSelectedItemCompleted,
selectedSubItemId,
handleSubItemSelect,
handleSubItemToggle,
handleConfirmComplete,
isMock: day1IsMock,
} = useDay1Audit();
// 1일차 상태
const [day1Categories, setDay1Categories] = useState<ChecklistCategory[]>(MOCK_DAY1_CATEGORIES);
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
// 점검표 템플릿 관리 훅 (설정 모달용)
const checklistTemplate = useChecklistTemplate();
// 2일차(로트추적) 필터 상태
const [selectedYear, setSelectedYear] = useState(2025);
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
const [searchTerm, setSearchTerm] = useState('');
// 업로드된 파일 상태 (subItemId별)
const [uploadedFiles, setUploadedFiles] = useState<Record<string, TemplateDocument[]>>({});
const [selectedUploadedFile, setSelectedUploadedFile] = useState<TemplateDocument | null>(null);
// 2일차 선택 상태
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
// 선택된 항목 변경 시 파일 목록 로드 + 업로드 파일 선택 초기화
useEffect(() => {
setSelectedUploadedFile(null);
if (!selectedSubItemId || !templateId) return;
if (uploadedFiles[selectedSubItemId]) return; // 이미 로드됨
// 2일차 루트 데이터 상태 (완료 토글용)
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(MOCK_ROUTES_INITIAL);
getTemplateDocuments(templateId, selectedSubItemId).then((result) => {
if (result.success && result.data) {
setUploadedFiles((prev) => ({ ...prev, [selectedSubItemId]: result.data! }));
}
// 모달 상태
const [modalOpen, setModalOpen] = useState(false);
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
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++;
});
});
});
}, [selectedSubItemId, templateId]);
return { completed, total };
}, [routesData]);
// 파일 업로드 핸들러
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,
isMock: day2IsMock,
} = useDay2LotAudit();
// 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션)
// ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) =====
const filteredDay1Categories = useMemo(() => {
if (displaySettings.showCompletedItems) return categories;
if (displaySettings.showCompletedItems) return day1Categories;
return categories.map(category => ({
return day1Categories.map(category => ({
...category,
subItems: category.subItems.filter(item => !item.isCompleted),
})).filter(category => category.subItems.length > 0);
}, [categories, displaySettings.showCompletedItems]);
}, [day1Categories, 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 (
<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">
<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">
{/* 헤더 (설정 버튼 포함) */}
<Header
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
@@ -195,9 +283,9 @@ export default function QualityInspectionPage() {
{activeDay === 1 ? (
// ===== 기준/매뉴얼 심사 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-[500px]">
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
{/* 좌측: 점검표 항목 */}
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto ${
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
? 'lg:col-span-3'
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
@@ -210,69 +298,59 @@ export default function QualityInspectionPage() {
searchTerm={searchTerm}
onSubItemSelect={handleSubItemSelect}
onSubItemToggle={handleSubItemToggle}
isMock={day1IsMock}
/>
</div>
{/* 중앙: 기준 문서화 */}
{displaySettings.showDocumentSection && (
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
}`}>
<Day1DocumentSection
checkItem={selectedCheckItem}
selectedDocumentId={selectedStandardDocId}
onDocumentSelect={setSelectedStandardDocId}
onConfirmComplete={handleConfirmComplete}
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>
)}
{/* 우측: 문서 뷰어 */}
{displaySettings.showDocumentViewer && (
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
}`}>
<Day1DocumentViewer document={null} uploadedFile={selectedUploadedFile} isMock={day1IsMock} />
<Day1DocumentViewer document={selectedStandardDoc} />
</div>
)}
</div>
) : (
// ===== 로트 추적 심사 심사 =====
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-[500px]">
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto">
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
<ReportList
reports={filteredReports}
selectedId={selectedReport?.id || null}
onSelect={handleReportSelect}
isMock={day2IsMock}
/>
</div>
<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">
<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">
<RouteList
routes={currentRoutes}
selectedId={selectedRoute?.id || null}
onSelect={handleRouteSelect}
onToggleItem={handleToggleItem}
reportCode={selectedReport?.code || null}
isMock={day2IsMock}
/>
</div>
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
<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">
<DocumentList
documents={currentDocuments}
routeCode={selectedRoute?.code || null}
onViewDocument={handleViewDocument}
isMock={day2IsMock}
/>
</div>
</div>
@@ -284,25 +362,6 @@ export default function QualityInspectionPage() {
onClose={() => setSettingsOpen(false)}
settings={displaySettings}
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

View File

@@ -14,7 +14,6 @@ export interface RouteItem {
id: string;
code: string; // e.g., KD-SS-240924-19
date: string; // 2024-09-24
client: string; // 거래처(발주처)
site: string; // 강남 아파트 A동
locationCount: number;
subItems: UnitInspection[];
@@ -43,8 +42,6 @@ export interface DocumentItem {
code?: string;
// 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용)
subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
// 작업일지/중간검사에서 실제 WorkOrder 데이터 로딩용
workOrderId?: number;
}
// ===== 기준/매뉴얼 심사 심사 타입 =====
@@ -95,28 +92,3 @@ export interface Day2Progress {
completed: 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;
}

View File

@@ -24,6 +24,7 @@ import {
Plus,
Users,
CheckCircle,
XCircle,
Loader2,
Bell,
} from "lucide-react";
@@ -57,13 +58,13 @@ export default function CustomerAccountManagementPage() {
const {
clients,
pagination,
isLoading: _isLoading,
isLoading,
fetchClients,
deleteClient: deleteClientApi,
} = useClientList();
const [searchTerm, _setSearchTerm] = useState("");
const [filterType, _setFilterType] = useState("all");
const [searchTerm, setSearchTerm] = useState("");
const [filterType, setFilterType] = useState("all");
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
@@ -175,7 +176,7 @@ export default function CustomerAccountManagementPage() {
const paginatedClients = filteredClients;
// 모바일용 인피니티 스크롤 데이터
const _mobileClients = filteredClients.slice(0, mobileDisplayCount);
const mobileClients = filteredClients.slice(0, mobileDisplayCount);
// Intersection Observer를 이용한 인피니티 스크롤
useEffect(() => {
@@ -261,12 +262,12 @@ export default function CustomerAccountManagementPage() {
// 테이블 컬럼 정의 (Hooks 순서 보장을 위해 조건부 return 전에 정의)
const tableColumns: TableColumn[] = useMemo(() => [
{ key: "rowNumber", label: "번호", className: "px-4" },
{ key: "code", label: "코드", className: "px-4", sortable: true, copyable: true },
{ key: "code", label: "코드", className: "px-4", sortable: true },
{ key: "clientType", label: "구분", className: "px-4", sortable: true },
{ key: "name", label: "거래처명", className: "px-4", sortable: true, copyable: true },
{ key: "representative", label: "대표자", className: "px-4", sortable: true, copyable: true },
{ key: "managerName", label: "담당자", className: "px-4", sortable: true, copyable: true },
{ key: "phone", label: "전화번호", className: "px-4", sortable: true, copyable: true },
{ key: "name", label: "거래처명", className: "px-4", sortable: true },
{ key: "representative", label: "대표자", className: "px-4", sortable: true },
{ key: "manager", label: "담당자", className: "px-4", sortable: true },
{ key: "phone", label: "전화번호", className: "px-4", sortable: true },
], []);
// 핸들러 - 페이지 기반 네비게이션
@@ -274,7 +275,7 @@ export default function CustomerAccountManagementPage() {
router.push("/sales/client-management-sales-admin?mode=new");
};
const _handleEdit = (customer: Client) => {
const handleEdit = (customer: Client) => {
router.push(`/sales/client-management-sales-admin/${customer.id}?mode=edit`);
};
@@ -282,7 +283,7 @@ export default function CustomerAccountManagementPage() {
router.push(`/sales/client-management-sales-admin/${customer.id}?mode=view`);
};
const _handleDelete = (customerId: string) => {
const handleDelete = (customerId: string) => {
setDeleteTargetId(customerId);
setIsDeleteDialogOpen(true);
};
@@ -303,7 +304,7 @@ export default function CustomerAccountManagementPage() {
};
// 체크박스 선택
const _toggleSelection = (id: string) => {
const toggleSelection = (id: string) => {
const newSelection = new Set(selectedItems);
if (newSelection.has(id)) {
newSelection.delete(id);
@@ -313,7 +314,7 @@ export default function CustomerAccountManagementPage() {
setSelectedItems(newSelection);
};
const _toggleSelectAll = () => {
const toggleSelectAll = () => {
if (
selectedItems.size === paginatedClients.length &&
paginatedClients.length > 0
@@ -325,7 +326,7 @@ export default function CustomerAccountManagementPage() {
};
// 일괄 삭제
const _handleBulkDelete = () => {
const handleBulkDelete = () => {
if (selectedItems.size === 0) {
toast.error("삭제할 항목을 선택해주세요");
return;

View File

@@ -27,6 +27,9 @@ import {
PenLine,
Factory,
XCircle,
FileSpreadsheet,
FileCheck,
ClipboardList,
Eye,
CheckCircle2,
RotateCcw,
@@ -272,12 +275,12 @@ export default function OrderDetailPage() {
setIsProductionSuccessDialogOpen(false);
};
const _handleViewProductionOrder = () => {
const handleViewProductionOrder = () => {
// 생산지시 목록 페이지로 이동 (수주관리 내부)
router.push(`/sales/order-management-sales/production-orders`);
};
const _handleCancel = () => {
const handleCancel = () => {
setCancelReason("");
setCancelDetail("");
setIsCancelDialogOpen(true);
@@ -429,7 +432,7 @@ export default function OrderDetailPage() {
};
// 수주 삭제
const _handleDelete = () => {
const handleDelete = () => {
setIsDeleteDialogOpen(true);
};

View File

@@ -54,7 +54,7 @@ import type { Process } from "@/types/process";
import { formatAmount } from "@/lib/utils/amount";
// 수주 정보 타입
interface _OrderInfo {
interface OrderInfo {
orderNumber: string;
client: string;
siteName: string;
@@ -76,7 +76,7 @@ interface PriorityConfig {
}
// 작업지시 카드 타입
interface _WorkOrderCard {
interface WorkOrderCard {
id: string;
type: string;
orderNumber: string;
@@ -86,7 +86,7 @@ interface _WorkOrderCard {
}
// 자재 소요량 타입
interface _MaterialRequirement {
interface MaterialRequirement {
materialCode: string;
materialName: string;
unit: string;
@@ -109,7 +109,7 @@ interface ScreenItemDetail {
}
// 가이드레일 BOM 타입
interface _GuideRailBom {
interface GuideRailBom {
type: string;
spec: string;
code: string;
@@ -118,14 +118,14 @@ interface _GuideRailBom {
}
// 케이스 BOM 타입
interface _CaseBom {
interface CaseBom {
item: string;
length: string;
quantity: number;
}
// 하단 마감재 BOM 타입
interface _BottomFinishBom {
interface BottomFinishBom {
item: string;
spec: string;
length: string;

View File

@@ -100,7 +100,7 @@ function CreateOrderContent() {
} else {
toast.error(result.error || "수주 등록에 실패했습니다.");
}
} catch (_error) {
} catch (error) {
toast.error("수주 등록 중 오류가 발생했습니다.");
}
};
@@ -231,14 +231,14 @@ function OrderListContent() {
});
// 페이지네이션
const _totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
const paginatedOrders = filteredOrders.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
// 모바일용 인피니티 스크롤 데이터
const _mobileOrders = filteredOrders.slice(0, mobileDisplayCount);
const mobileOrders = filteredOrders.slice(0, mobileDisplayCount);
// Intersection Observer를 이용한 인피니티 스크롤
useEffect(() => {
@@ -367,7 +367,7 @@ function OrderListContent() {
// 다중 선택 삭제 (IntegratedListTemplateV2에서 확인 후 호출됨)
// 템플릿 내부에서 이미 확인 팝업을 처리하므로 바로 삭제 실행
const _handleBulkDelete = async () => {
const handleBulkDelete = async () => {
const selectedIds = Array.from(selectedItems);
if (selectedIds.length > 0) {
setIsDeleting(true);
@@ -532,20 +532,20 @@ function OrderListContent() {
// 테이블 컬럼 정의 (16개: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고)
const tableColumns: TableColumn[] = useMemo(() => [
{ key: "rowNumber", label: "번호", className: "px-2 text-center" },
{ key: "lotNumber", label: "로트번호", className: "px-2", sortable: true, copyable: true },
{ key: "siteName", label: "현장명", className: "px-2", sortable: true, copyable: true },
{ key: "expectedShipDate", label: "출고예정일", className: "px-2", sortable: true, copyable: true },
{ key: "orderDate", label: "수주일", className: "px-2", sortable: true, copyable: true },
{ key: "client", label: "수주처", className: "px-2", sortable: true, copyable: true },
{ key: "productName", label: "제품명", className: "px-2", sortable: true, copyable: true },
{ key: "receiver", label: "수신자", className: "px-2", sortable: true, copyable: true },
{ key: "receiverAddress", label: "수신주소", className: "px-2", sortable: true, copyable: true },
{ key: "receiverPlace", label: "수신처", className: "px-2", sortable: true, copyable: true },
{ key: "deliveryMethod", label: "배송", className: "px-2", sortable: true, copyable: true },
{ key: "manager", label: "담당자", className: "px-2", sortable: true, copyable: true },
{ key: "frameCount", label: "틀수", className: "px-2 text-center", sortable: true, copyable: true },
{ key: "lotNumber", label: "로트번호", className: "px-2", sortable: true },
{ key: "siteName", label: "현장명", className: "px-2", sortable: true },
{ key: "expectedShipDate", label: "출고예정일", className: "px-2", sortable: true },
{ key: "orderDate", label: "수주일", className: "px-2", sortable: true },
{ key: "client", label: "수주처", className: "px-2", sortable: true },
{ key: "productName", label: "제품명", className: "px-2", sortable: true },
{ key: "receiver", label: "수신자", className: "px-2", sortable: true },
{ key: "receiverAddress", label: "수신주소", className: "px-2", sortable: true },
{ key: "receiverPlace", label: "수신처", className: "px-2", sortable: true },
{ key: "deliveryMethod", label: "배송", className: "px-2", sortable: true },
{ key: "manager", label: "담당자", className: "px-2", sortable: true },
{ key: "frameCount", label: "틀수", className: "px-2 text-center", sortable: true },
{ key: "status", label: "상태", className: "px-2", sortable: true },
{ key: "remarks", label: "비고", className: "px-2", copyable: true },
{ key: "remarks", label: "비고", className: "px-2" },
], []);
// 테이블 행 렌더링 (16개 컬럼: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고)

View File

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

View File

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

View File

@@ -24,8 +24,8 @@ interface PricingDetailPageProps {
export default function PricingDetailPage({ params }: PricingDetailPageProps) {
const { id } = use(params);
const _router = useRouter();
const _searchParams = useSearchParams();
const router = useRouter();
const searchParams = useSearchParams();
const mode: 'create' | 'edit' = 'edit';
const [data, setData] = useState<PricingData | null>(null);
const [isLoading, setIsLoading] = useState(true);

View File

@@ -74,7 +74,7 @@ export default function QuoteDetailPage() {
if (calcResult.success && calcResult.data?.items) {
// 재계산 결과를 locations에 적용
const updatedLocations = v2Data.locations.map((loc, _index) => {
const updatedLocations = v2Data.locations.map((loc, index) => {
// productCode가 있고 bomResult가 없는 경우에만 업데이트
if (!loc.bomResult && loc.productCode) {
const calcItem = calcResult.data?.items.find(
@@ -89,7 +89,6 @@ export default function QuoteDetailPage() {
v2Data.locations = updatedLocations;
} else {
// no BOM result to merge
}
}

View File

@@ -1,112 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { transformApiMenusToMenuItems } from '@/lib/utils/menuTransform';
import { performFullLogout } from '@/lib/auth/logout';
/**
* MNG 관리자 패널 → SAM 자동 로그인 페이지
*
* 흐름:
* 1. MNG에서 "SAM 접속" 클릭 → /auto-login?token=xxx 로 새 창 열림
* 2. 기존 세션 로그아웃 (쿠키 + localStorage + Zustand 초기화)
* 3. One-Time Token으로 API 호출 → 새 세션 생성
* 4. 사용자 정보 저장 후 /dashboard로 이동
*/
export default function AutoLoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<'processing' | 'error'>('processing');
const [errorMessage, setErrorMessage] = useState('');
useEffect(() => {
const token = searchParams.get('token');
if (!token) {
setStatus('error');
setErrorMessage('로그인 토큰이 없습니다.');
return;
}
const performAutoLogin = async () => {
try {
// 1. 기존 세션 완전 로그아웃 (쿠키 삭제 + 스토어 초기화)
await performFullLogout({ skipServerLogout: false });
// 2. One-Time Token으로 로그인
const response = await fetch('/api/auth/token-login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || '자동 로그인에 실패했습니다.');
}
// 3. 사용자 정보 localStorage 저장 (LoginPage와 동일 패턴)
const transformedMenus = transformApiMenusToMenuItems(data.menus || []);
const userData = {
id: data.user?.id,
name: data.user?.name,
position: data.roles?.[0]?.description || '사용자',
userId: data.user?.user_id,
department: data.user?.department || null,
department_id: data.user?.department_id || null,
menu: transformedMenus,
roles: data.roles || [],
tenant: data.tenant || {},
};
localStorage.setItem('user', JSON.stringify(userData));
// 4. persist store rehydrate
const { useFavoritesStore } = await import('@/stores/favoritesStore');
const { useTableColumnStore } = await import('@/stores/useTableColumnStore');
useFavoritesStore.persist.rehydrate();
useTableColumnStore.persist.rehydrate();
// 5. 로그인 플래그 설정
sessionStorage.setItem('auth_just_logged_in', 'true');
// 6. 대시보드로 이동
router.push('/dashboard');
} catch (err) {
console.error('자동 로그인 실패:', err);
setStatus('error');
setErrorMessage(err instanceof Error ? err.message : '자동 로그인에 실패했습니다.');
}
};
performAutoLogin();
}, [searchParams, router]);
if (status === 'error') {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4 p-8">
<div className="text-destructive text-lg font-semibold"> </div>
<p className="text-muted-foreground">{errorMessage}</p>
<button
onClick={() => router.push('/login')}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition"
>
</button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-center space-y-4">
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
<p className="text-muted-foreground"> ...</p>
</div>
</div>
);
}

View File

@@ -1,107 +0,0 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
/**
* 🔵 Next.js 내부 API - 토큰 자동 로그인 프록시
*
* MNG 관리자 패널에서 "SAM 접속" 버튼 클릭 시 사용
* One-Time Token으로 사용자 인증 후 HttpOnly 쿠키 설정
*
* 🔄 동작 흐름:
* 1. 클라이언트 → Next.js /api/auth/token-login (token)
* 2. Next.js → PHP /api/v1/token-login (토큰 검증)
* 3. PHP → Next.js (access_token, refresh_token, 사용자 정보)
* 4. Next.js: 토큰을 HttpOnly 쿠키로 설정
* 5. Next.js → 클라이언트 (토큰 제외한 사용자 정보만 전달)
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { token } = body;
if (!token) {
return NextResponse.json(
{ error: '토큰이 필요합니다.' },
{ status: 400 }
);
}
// PHP 백엔드 API 호출
const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/token-login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-API-KEY': process.env.API_KEY || '',
},
body: JSON.stringify({ token }),
});
if (!backendResponse.ok) {
const errorData = await backendResponse.json().catch(() => ({}));
return NextResponse.json(
{ error: errorData.error || '토큰 인증에 실패했습니다.' },
{ status: backendResponse.status }
);
}
const data = await backendResponse.json();
// 클라이언트에 전달할 응답 (토큰 제외)
const responseData = {
message: data.message,
user: data.user,
tenant: data.tenant,
menus: data.menus,
roles: data.roles,
token_type: data.token_type,
expires_in: data.expires_in,
expires_at: data.expires_at,
};
// HttpOnly 쿠키 설정 (login/route.ts와 동일한 패턴)
const isProduction = process.env.NODE_ENV === 'production';
const accessTokenCookie = [
`access_token=${data.access_token}`,
'HttpOnly',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
`Max-Age=${data.expires_in || 7200}`,
].join('; ');
const refreshTokenCookie = [
`refresh_token=${data.refresh_token}`,
'HttpOnly',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
'Max-Age=604800',
].join('; ');
const isAuthenticatedCookie = [
'is_authenticated=true',
...(isProduction ? ['Secure'] : []),
'SameSite=Lax',
'Path=/',
`Max-Age=${data.expires_in || 7200}`,
].join('; ');
const response = NextResponse.json(responseData, { status: 200 });
response.headers.append('Set-Cookie', accessTokenCookie);
response.headers.append('Set-Cookie', refreshTokenCookie);
response.headers.append('Set-Cookie', isAuthenticatedCookie);
return response;
} catch (error) {
console.error('Token login proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from 'next/server';
import { authenticatedFetch } from '@/lib/api/authenticated-fetch';
import { stripJsonTrailingData } from '@/lib/api/safe-json-parse';
/**
* 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern)
@@ -191,35 +190,14 @@ async function proxyRequest(
},
});
} else {
let responseData = await backendResponse.text();
const responseData = await backendResponse.text();
// 백엔드가 HTML 에러 페이지를 반환한 경우 (404/500 등)
// HTML을 그대로 전달하면 클라이언트 response.json()에서 SyntaxError 발생
// → 안전한 JSON 에러 응답으로 변환
if (!backendResponse.ok && !responseData.trimStart().startsWith('{') && !responseData.trimStart().startsWith('[')) {
const status = backendResponse.status;
clientResponse = NextResponse.json(
{
success: false,
message: status === 404
? '요청한 API를 찾을 수 없습니다.'
: `서버 오류가 발생했습니다. (${status})`,
error: { code: status },
},
{ status }
);
} else {
// PHP trailing output 제거 (JSON 뒤에 warning/error 텍스트가 붙는 경우)
if (responseContentType.includes('application/json') || responseData.trimStart().startsWith('{') || responseData.trimStart().startsWith('[')) {
responseData = stripJsonTrailingData(responseData);
}
clientResponse = new NextResponse(responseData, {
status: backendResponse.status,
headers: {
'Content-Type': responseContentType,
},
});
}
clientResponse = new NextResponse(responseData, {
status: backendResponse.status,
headers: {
'Content-Type': responseContentType,
},
});
}
// 8. 토큰이 갱신되었으면 새 쿠키 설정

View File

@@ -6,7 +6,6 @@
*/
import { useState, useCallback, useMemo } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
@@ -138,14 +137,12 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
if (isNewMode) {
const result = await createBadDebt(formData);
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '등록에 실패했습니다.' };
} else {
const result = await updateBadDebt(recordId!, formData);
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '수정에 실패했습니다.' };
@@ -162,7 +159,6 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
try {
const result = await deleteBadDebt(String(id));
if (result.success) {
invalidateDashboard('badDebt');
return { success: true };
}
return { success: false, error: result.error || '삭제에 실패했습니다.' };
@@ -271,7 +267,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
}, [router, formData.vendorId]);
// 파일 다운로드 핸들러
const handleFileDownload = useCallback((_fileName: string) => {
const handleFileDownload = useCallback((fileName: string) => {
// TODO: 실제 다운로드 로직
}, []);

View File

@@ -8,7 +8,7 @@
*/
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import { BadDebtDetail } from './BadDebtDetail';
import { getBadDebtById } from './actions';
import type { BadDebtRecord } from './types';
@@ -26,6 +26,7 @@ interface BadDebtDetailClientV2Props {
const BASE_PATH = '/ko/accounting/bad-debt-collection';
export function BadDebtDetailClientV2({ recordId, initialMode }: BadDebtDetailClientV2Props) {
const router = useRouter();
const searchParams = useSearchParams();
// URL 쿼리에서 모드 결정

View File

@@ -18,7 +18,7 @@ import { revalidatePath } from 'next/cache';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { PaginatedApiResponse } from '@/lib/api/types';
import type { BadDebtRecord, CollectionStatus } from './types';
import type { BadDebtRecord, BadDebtItem, CollectionStatus } from './types';
// ===== API 응답 타입 =====

View File

@@ -14,7 +14,6 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
*/
import { useState, useMemo, useCallback, useTransition } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -51,10 +50,10 @@ import { deleteBadDebt, toggleBadDebt } from './actions';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
{ key: 'vendorName', label: '거래처', className: 'w-[100px]', sortable: true, copyable: true },
{ key: 'debtAmount', label: '채권금액', className: 'text-right w-[140px]', sortable: true, copyable: true },
{ key: 'occurrenceDate', label: '발생일', className: 'text-center w-[110px]', sortable: true, copyable: true },
{ key: 'overdueDays', label: '연체일수', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처', className: 'w-[100px]', sortable: true },
{ key: 'debtAmount', label: '채권금액', className: 'text-right w-[140px]', sortable: true },
{ key: 'occurrenceDate', label: '발생일', className: 'text-center w-[110px]', sortable: true },
{ key: 'overdueDays', label: '연체일수', className: 'text-center w-[100px]', sortable: true },
{ key: 'managerName', label: '담당자', className: 'w-[100px]', sortable: true },
{ key: 'status', label: '상태', className: 'text-center w-[100px]', sortable: true },
{ key: 'setting', label: '설정', className: 'text-center w-[80px]' },
@@ -177,7 +176,6 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
deleteItem: async (id: string) => {
const result = await deleteBadDebt(id);
if (result.success) {
invalidateDashboard('badDebt');
setData((prev) => prev.filter((item) => item.id !== id));
}
return { success: result.success, error: result.error };

View File

@@ -76,15 +76,15 @@ const excelColumns: ExcelColumn<BankTransaction & Record<string, unknown>>[] = [
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
const tableColumns = [
{ key: 'rowNumber', label: 'No.', className: 'text-center w-[50px]' },
{ key: 'transactionDate', label: '거래일시', sortable: true, copyable: true },
{ key: 'type', label: '구분', className: 'text-center', sortable: true, copyable: true },
{ key: 'accountInfo', label: '계좌정보', sortable: true, copyable: true },
{ key: 'note', label: '적요/내용', sortable: true, copyable: true },
{ key: 'depositAmount', label: '입금', className: 'text-right', sortable: true, copyable: true },
{ key: 'withdrawalAmount', label: '출금', className: 'text-right', sortable: true, copyable: true },
{ key: 'balance', label: '잔액', className: 'text-right', sortable: true, copyable: true },
{ key: 'branch', label: '취급점', className: 'text-center', sortable: true, copyable: true },
{ key: 'depositorName', label: '상대계좌예금주명', sortable: true, copyable: true },
{ key: 'transactionDate', label: '거래일시', sortable: true },
{ key: 'type', label: '구분', className: 'text-center', sortable: true },
{ key: 'accountInfo', label: '계좌정보', sortable: true },
{ key: 'note', label: '적요/내용', sortable: true },
{ key: 'depositAmount', label: '입금', className: 'text-right', sortable: true },
{ key: 'withdrawalAmount', label: '출금', className: 'text-right', sortable: true },
{ key: 'balance', label: '잔액', className: 'text-right', sortable: true },
{ key: 'branch', label: '취급점', className: 'text-center', sortable: true },
{ key: 'depositorName', label: '상대계좌예금주명', sortable: true },
];
// ===== 기본 Summary =====
@@ -112,7 +112,7 @@ export function BankTransactionInquiry() {
// 필터 상태
const [searchQuery, setSearchQuery] = useState('');
const [sortOption] = useState<SortOption>('latest');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [accountCategoryFilter, setAccountCategoryFilter] = useState<AccountCategoryFilter>('all');
const [financialInstitutionFilter, setFinancialInstitutionFilter] = useState('all');
const [currentPage, setCurrentPage] = useState(1);

View File

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

View File

@@ -17,12 +17,13 @@ import { useDateRange } from '@/hooks';
import {
FileText,
Plus,
RefreshCw,
Save,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
import { TableRow, TableCell } from '@/components/ui/table';
import {
Select,
@@ -42,6 +43,7 @@ import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import type {
BillRecord,
BillType,
BillStatus,
SortOption,
} from './types';
@@ -50,8 +52,6 @@ import {
BILL_TYPE_FILTER_OPTIONS,
BILL_STATUS_COLORS,
BILL_STATUS_FILTER_OPTIONS,
RECEIVED_BILL_STATUS_OPTIONS,
ISSUED_BILL_STATUS_OPTIONS,
getBillStatusLabel,
} from './types';
import { getBills, deleteBill, updateBillStatus } from './actions';
@@ -81,15 +81,32 @@ export function BillManagementClient({
const [pagination, setPagination] = useState(initialPagination);
const [isLoading, setIsLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [sortOption] = useState<SortOption>('latest');
const [sortOption, setSortOption] = useState<SortOption>('latest');
const [billTypeFilter, setBillTypeFilter] = useState<string>(initialBillType || 'received');
const [vendorFilter, setVendorFilter] = useState<string>(initialVendorId || 'all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [targetStatus, setTargetStatus] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
const itemsPerPage = initialPagination.perPage;
// 삭제 다이얼로그
const deleteDialog = useDeleteDialog({
onDelete: async (id) => {
const result = await deleteBill(id);
if (result.success) {
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
await loadData(currentPage);
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return result;
},
entityName: '어음',
});
// 날짜 범위 상태
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
@@ -172,13 +189,13 @@ export function BillManagementClient({
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'billNumber', label: '어음번호', sortable: true, copyable: true },
{ key: 'billNumber', label: '어음번호', sortable: true },
{ key: 'billType', label: '구분', className: 'text-center', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
{ key: 'amount', label: '금액', className: 'text-right', sortable: true, copyable: true },
{ key: 'issueDate', label: '발행일', sortable: true, copyable: true },
{ key: 'maturityDate', label: '만기일', sortable: true, copyable: true },
{ key: 'installmentCount', label: '차수', className: 'text-center', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'amount', label: '금액', className: 'text-right', sortable: true },
{ key: 'issueDate', label: '발행일', sortable: true },
{ key: 'maturityDate', label: '만기일', sortable: true },
{ key: 'installmentCount', label: '차수', className: 'text-center', sortable: true },
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
], []);
@@ -264,15 +281,15 @@ export function BillManagementClient({
];
}, [data]);
// ===== 상태 변경 핸들러 =====
const handleStatusChange = useCallback(async () => {
// ===== 저장 핸들러 =====
const handleSave = useCallback(async () => {
if (selectedItems.size === 0) {
toast.warning('선택된 항목이 없습니다.');
return;
}
if (!targetStatus) {
toast.warning('변경할 상태를 선택해주세요.');
if (statusFilter === 'all') {
toast.warning('상태를 선택해주세요.');
return;
}
@@ -280,28 +297,21 @@ export function BillManagementClient({
let successCount = 0;
for (const id of selectedItems) {
const result = await updateBillStatus(id, targetStatus as BillStatus);
const result = await updateBillStatus(id, statusFilter as BillStatus);
if (result.success) {
successCount++;
}
}
if (successCount > 0) {
invalidateDashboard('bill');
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
toast.success(`${successCount}건이 저장되었습니다.`);
loadData(currentPage);
setSelectedItems(new Set());
setTargetStatus('');
} else {
toast.error('상태 변경에 실패했습니다.');
toast.error('저장에 실패했습니다.');
}
setIsLoading(false);
}, [selectedItems, targetStatus, loadData, currentPage]);
// 구분에 따른 상태 옵션
const statusChangeOptions = useMemo(() => {
return billTypeFilter === 'issued' ? ISSUED_BILL_STATUS_OPTIONS : RECEIVED_BILL_STATUS_OPTIONS;
}, [billTypeFilter]);
}, [selectedItems, statusFilter, loadData, currentPage]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<BillRecord> = useMemo(
@@ -324,25 +334,6 @@ export function BillManagementClient({
totalCount: pagination.total,
};
},
deleteItem: async (id: string) => {
const result = await deleteBill(id);
if (result.success) {
invalidateDashboard('bill');
await loadData(currentPage);
setSelectedItems(prev => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
return { success: result.success, error: result.error };
},
},
// 삭제 확인 메시지
deleteConfirmMessage: {
title: '어음 삭제',
description: '이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
},
// 테이블 컬럼
@@ -385,30 +376,12 @@ export function BillManagementClient({
icon: Plus,
},
// 선택 시 상태 변경 액션
selectionActions: () => (
<div className="flex items-center gap-2">
<Select value={targetStatus} onValueChange={setTargetStatus}>
<SelectTrigger className="min-w-[130px] w-auto h-8">
<SelectValue placeholder="상태 선택" />
</SelectTrigger>
<SelectContent>
{statusChangeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={handleStatusChange}
disabled={!targetStatus || isLoading}
>
<RefreshCw className="h-4 w-4 mr-1" />
</Button>
</div>
// 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리)
headerActions: () => (
<Button onClick={handleSave} size="sm" disabled={isLoading}>
<Save className="h-4 w-4 mr-1" />
</Button>
),
// 테이블 헤더 액션 (필터)
@@ -472,10 +445,7 @@ export function BillManagementClient({
isLoading,
router,
loadData,
currentPage,
handleStatusChange,
statusChangeOptions,
targetStatus,
handleSave,
renderTableRow,
renderMobileCard,
]
@@ -501,6 +471,14 @@ export function BillManagementClient({
}}
/>
<DeleteConfirmDialog
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="어음 삭제"
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={deleteDialog.isPending}
/>
</>
);
}

View File

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

View File

@@ -16,7 +16,6 @@ import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { formatNumber } from '@/lib/utils/amount';
import { getBills, deleteBill, updateBillStatus } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import { extractUniqueOptions } from '../shared';
import {
@@ -62,13 +61,13 @@ import {
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'billNumber', label: '어음번호', sortable: true, copyable: true },
{ key: 'billNumber', label: '어음번호', sortable: true },
{ key: 'billType', label: '구분', className: 'text-center', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
{ key: 'amount', label: '금액', className: 'text-right', sortable: true, copyable: true },
{ key: 'issueDate', label: '발행일', sortable: true, copyable: true },
{ key: 'maturityDate', label: '만기일', sortable: true, copyable: true },
{ key: 'installmentCount', label: '차수', className: 'text-center', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'amount', label: '금액', className: 'text-right', sortable: true },
{ key: 'issueDate', label: '발행일', sortable: true },
{ key: 'maturityDate', label: '만기일', sortable: true },
{ key: 'installmentCount', label: '차수', className: 'text-center', sortable: true },
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];
@@ -94,7 +93,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
const [billTypeFilter, setBillTypeFilter] = useState<string>(initialBillType || 'received');
const [vendorFilter, setVendorFilter] = useState<string>(initialVendorId || 'all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortOption, _setSortOption] = useState<SortOption>('latest');
const [sortOption, setSortOption] = useState<SortOption>('latest');
// 페이지네이션
const [currentPage, setCurrentPage] = useState(1);
@@ -210,7 +209,6 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
}
if (successCount > 0) {
invalidateDashboard('bill');
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
await loadBills();
}
@@ -249,7 +247,6 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
deleteItem: async (id: string) => {
const result = await deleteBill(id);
if (result.success) {
invalidateDashboard('bill');
// 서버에서 재조회 (pagination 메타데이터 포함)
await loadBills();
}

View File

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

View File

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

View File

@@ -48,7 +48,7 @@ interface CardTransactionApiSummary {
// ===== API → Frontend 변환 =====
function transformItem(item: CardTransactionApiItem): CardTransaction {
const card = item.card;
const _cardDisplay = card ? `${card.card_company} ${card.card_number_last4}` : '-';
const cardDisplay = card ? `${card.card_company} ${card.card_number_last4}` : '-';
const usedAtRaw = item.used_at || item.withdrawal_date;
const usedAtDate = new Date(usedAtRaw);
const usedAt = item.used_at ? usedAtDate.toISOString().slice(0, 16).replace('T', ' ') : item.withdrawal_date;

View File

@@ -42,7 +42,6 @@ import type { CardTransaction, InlineEditData, SortOption } from './types';
import {
SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS,
} from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import {
getCardTransactionList,
getCardTransactionSummary,
@@ -83,19 +82,19 @@ const excelColumns: ExcelColumn<CardTransaction>[] = [
// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) =====
const tableColumns = [
{ key: 'rowNumber', label: 'No.', className: 'text-center w-[50px]' },
{ key: 'usedAt', label: '사용일시', className: 'min-w-[130px]', copyable: true },
{ key: 'cardCompany', label: '카드사', className: 'min-w-[80px]', copyable: true },
{ key: 'card', label: '카드번호', className: 'min-w-[100px]', copyable: true },
{ key: 'cardName', label: '카드명', className: 'min-w-[80px]', copyable: true },
{ key: 'usedAt', label: '사용일시', className: 'min-w-[130px]' },
{ key: 'cardCompany', label: '카드사', className: 'min-w-[80px]' },
{ key: 'card', label: '카드번호', className: 'min-w-[100px]' },
{ key: 'cardName', label: '카드명', className: 'min-w-[80px]' },
{ key: 'deductionType', label: '공제', className: 'min-w-[95px]', sortable: false },
{ key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]', copyable: true },
{ key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]', copyable: true },
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[160px]', sortable: false, copyable: true },
{ key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false, copyable: true },
{ key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right', copyable: true },
{ key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false, copyable: true },
{ key: 'taxAmount', label: '세액', className: 'min-w-[90px] text-right', sortable: false, copyable: true },
{ key: 'accountSubject', label: '계정과목', className: 'min-w-[100px]', sortable: false, copyable: true },
{ key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]' },
{ key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]' },
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[130px]', sortable: false },
{ key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false },
{ key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right' },
{ key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false },
{ key: 'taxAmount', label: '세액', className: 'min-w-[90px] text-right', sortable: false },
{ key: 'accountSubject', label: '계정과목', className: 'min-w-[100px]', sortable: false },
{ key: 'journalEntry', label: '분개', className: 'w-16 text-center', sortable: false },
{ key: 'hide', label: '숨김', className: 'w-16 text-center', sortable: false },
];
@@ -600,13 +599,20 @@ export function CardTransactionInquiry() {
</TableCell>
{/* 계정과목 (인라인 Select) */}
<TableCell onClick={(e) => e.stopPropagation()}>
<AccountSubjectSelect
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || ''}
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v)}
placeholder="선택"
size="sm"
className="min-w-[90px] w-auto"
/>
<Select
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || 'none'}
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v === 'none' ? '' : v)}
>
<SelectTrigger className="h-7 text-xs min-w-[90px] w-auto">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
{/* 분개 버튼 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>

View File

@@ -1,6 +1,8 @@
'use server';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { cookies } from 'next/headers';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/query-params';
import type { NoteReceivableItem, DailyAccountItem, MatchStatus } from './types';

View File

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

View File

@@ -20,8 +20,10 @@ import {
Plus,
Save,
RefreshCw,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
@@ -71,7 +73,6 @@ import { deleteDeposit, updateDepositTypes, getDeposits } from './actions';
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useDateRange } from '@/hooks';
import {
extractUniqueOptions,
@@ -82,13 +83,13 @@ import {
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
{ key: 'depositDate', label: '입금일', sortable: true, copyable: true },
{ key: 'accountName', label: '입금계좌', sortable: true, copyable: true },
{ key: 'depositorName', label: '입금자명', sortable: true, copyable: true },
{ key: 'depositAmount', label: '입금금액', className: 'text-right', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
{ key: 'note', label: '적요', sortable: true, copyable: true },
{ key: 'depositType', label: '입금유형', className: 'text-center', sortable: true, copyable: true },
{ key: 'depositDate', label: '입금일', sortable: true },
{ key: 'accountName', label: '입금계좌', sortable: true },
{ key: 'depositorName', label: '입금자명', sortable: true },
{ key: 'depositAmount', label: '입금금액', className: 'text-right', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'note', label: '적요', sortable: true },
{ key: 'depositType', label: '입금유형', className: 'text-center', sortable: true },
];
// ===== 컴포넌트 Props =====
@@ -102,7 +103,7 @@ interface DepositManagementProps {
};
}
export function DepositManagement({ initialData, initialPagination: _initialPagination }: DepositManagementProps) {
export function DepositManagement({ initialData, initialPagination }: DepositManagementProps) {
const router = useRouter();
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
@@ -224,7 +225,6 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
deleteItem: async (id: string) => {
const result = await deleteDeposit(id);
if (result.success) {
invalidateDashboard('deposit');
toast.success('입금 내역이 삭제되었습니다.');
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
await handleRefresh();

View File

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

View File

@@ -15,7 +15,6 @@ import { useState, useMemo, useCallback, useTransition, useEffect } from 'react'
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { toast } from 'sonner';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import {
Receipt,
Calendar as CalendarIcon,
@@ -59,7 +58,7 @@ import {
type RowClickHandlers,
type StatCard,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard } from '@/components/organisms/MobileCard';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import type {
ExpectedExpenseRecord,
TransactionType,
@@ -82,14 +81,15 @@ import {
getClients,
getBankAccounts,
} from './actions';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { CurrencyInput } from '@/components/ui/currency-input';
import {
TRANSACTION_TYPE_FILTER_OPTIONS,
PAYMENT_STATUS_FILTER_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
import { AccountSubjectSelect } from '@/components/accounting/common';
import { extractUniqueOptions } from '../shared';
import { formatNumber } from '@/lib/utils/amount';
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
@@ -218,7 +218,7 @@ export function ExpectedExpenseManagement({
}, [resetForm]);
// ===== 수정 다이얼로그 열기 =====
const _handleOpenEditDialog = useCallback((item: ExpectedExpenseRecord) => {
const handleOpenEditDialog = useCallback((item: ExpectedExpenseRecord) => {
setEditingItem(item);
setFormData({
expectedPaymentDate: item.expectedPaymentDate,
@@ -247,7 +247,6 @@ export function ExpectedExpenseManagement({
// 수정
const result = await updateExpectedExpense(editingItem.id, formData);
if (result.success && result.data) {
invalidateDashboard('expectedExpense');
setData(prev => prev.map(item => item.id === editingItem.id ? result.data! : item));
toast.success('미지급비용이 수정되었습니다.');
setShowFormDialog(false);
@@ -259,7 +258,6 @@ export function ExpectedExpenseManagement({
// 등록
const result = await createExpectedExpense(formData);
if (result.success && result.data) {
invalidateDashboard('expectedExpense');
setData(prev => [result.data!, ...prev]);
toast.success('미지급비용이 등록되었습니다.');
setShowFormDialog(false);
@@ -280,7 +278,6 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await deleteExpectedExpenses(selectedIds);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
setSelectedItems(new Set());
toast.success(`${result.deletedCount || selectedIds.length}건이 삭제되었습니다.`);
@@ -484,7 +481,7 @@ export function ExpectedExpenseManagement({
// ===== 액션 핸들러 =====
// 상세페이지 없음 - 행 클릭 시 이동하지 않음
const _handleDeleteClick = useCallback((id: string) => {
const handleDeleteClick = useCallback((id: string) => {
setDeleteTargetId(id);
setShowDeleteDialog(true);
}, []);
@@ -495,7 +492,6 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await deleteExpectedExpense(deleteTargetId);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.filter(item => item.id !== deleteTargetId));
setSelectedItems(prev => {
const newSet = new Set(prev);
@@ -526,7 +522,6 @@ export function ExpectedExpenseManagement({
startTransition(async () => {
const result = await updateExpectedPaymentDate(selectedIds, newExpectedDate);
if (result.success) {
invalidateDashboard('expectedExpense');
setData(prev => prev.map(item =>
selectedItems.has(item.id)
? { ...item, expectedPaymentDate: newExpectedDate }
@@ -560,11 +555,11 @@ export function ExpectedExpenseManagement({
// ===== 테이블 컬럼 =====
const tableColumns = useMemo(() => [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'expectedPaymentDate', label: '예상 지급일', sortable: true, copyable: true },
{ key: 'accountSubject', label: '항목', sortable: true, copyable: true },
{ key: 'amount', label: '지출금액', className: 'text-right', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
{ key: 'bankAccount', label: '계좌', sortable: true, copyable: true },
{ key: 'expectedPaymentDate', label: '예상 지급일', sortable: true },
{ key: 'accountSubject', label: '항목', sortable: true },
{ key: 'amount', label: '지출금액', className: 'text-right', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'bankAccount', label: '계좌', sortable: true },
{ key: 'approvalStatus', label: '전자결재', className: 'text-center', sortable: true },
], []);
@@ -587,9 +582,9 @@ export function ExpectedExpenseManagement({
// 컬럼 순서: 체크박스 + 번호 + 예상 지급일 + 항목 + 지출금액 + 거래처 + 계좌 + 전자결재 + 작업
const renderTableRow = useCallback((
item: TableRowData,
_index: number,
_globalIndex: number,
_handlers: SelectionHandlers & RowClickHandlers<TableRowData>
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<TableRowData>
) => {
// 월 헤더 행 (9개 컬럼)
if (item.rowType === 'monthHeader') {
@@ -699,9 +694,9 @@ export function ExpectedExpenseManagement({
// ===== 모바일 카드 렌더링 =====
const renderMobileCard = useCallback((
item: TableRowData,
_index: number,
_globalIndex: number,
_handlers: SelectionHandlers & RowClickHandlers<TableRowData>
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<TableRowData>
) => {
const isSelected = selectedItems.has(item.id);
const onToggle = () => toggleSelection(item.id);
@@ -1190,12 +1185,21 @@ export function ExpectedExpenseManagement({
<div className="space-y-2">
<Label></Label>
<AccountSubjectSelect
value={formData.accountSubject || ''}
<Select
value={formData.accountSubject}
onValueChange={(value) => setFormData(prev => ({ ...prev, accountSubject: value }))}
placeholder="계정과목 선택"
category="expense"
/>
>
<SelectTrigger>
<SelectValue placeholder="계정과목 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ import {
import { MobileCard } from '@/components/organisms/MobileCard';
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
import { getJournalEntries, getJournalSummary } from './actions';
import { AccountSubjectSettingModal } from '@/components/accounting/common';
import { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
import { ManualJournalEntryModal } from './ManualJournalEntryModal';
import { JournalEditModal } from './JournalEditModal';
import type { GeneralJournalRecord, GeneralJournalSummary, PeriodButtonValue } from './types';
@@ -38,19 +38,18 @@ import {
getPeriodDates,
} from './types';
import { formatNumber } from '@/lib/utils/amount';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
// ===== 테이블 컬럼 (기획서 기준 10개) =====
const tableColumns = [
{ key: 'date', label: '날짜', className: 'text-center w-[100px]', copyable: true },
{ key: 'description', label: '적요', className: 'w-[140px]', copyable: true },
{ key: 'depositAmount', label: '입금', className: 'text-right w-[100px]', copyable: true },
{ key: 'withdrawalAmount', label: '출금', className: 'text-right w-[100px]', copyable: true },
{ key: 'balance', label: '잔액', className: 'text-right w-[100px]', copyable: true },
{ key: 'division', label: '구분', className: 'text-center w-[70px]', copyable: true },
{ key: 'journalDescription', label: '내역', className: 'w-[100px]', copyable: true },
{ key: 'debitAmount', label: '차변', className: 'text-right w-[90px]', copyable: true },
{ key: 'creditAmount', label: '대변', className: 'text-right w-[90px]', copyable: true },
{ key: 'date', label: '날짜', className: 'text-center w-[100px]' },
{ key: 'description', label: '적요', className: 'w-[140px]' },
{ key: 'depositAmount', label: '입금', className: 'text-right w-[100px]' },
{ key: 'withdrawalAmount', label: '출금', className: 'text-right w-[100px]' },
{ key: 'balance', label: '잔액', className: 'text-right w-[100px]' },
{ key: 'division', label: '구분', className: 'text-center w-[70px]' },
{ key: 'journalDescription', label: '내역', className: 'w-[100px]' },
{ key: 'debitAmount', label: '차변', className: 'text-right w-[90px]' },
{ key: 'creditAmount', label: '대변', className: 'text-right w-[90px]' },
{ key: 'journalAction', label: '분개', className: 'text-center w-[70px]', sortable: false },
];
@@ -152,14 +151,12 @@ export function GeneralJournalEntry() {
const handleManualEntrySuccess = useCallback(() => {
setShowManualEntry(false);
loadData();
invalidateDashboard('journalEntry');
}, [loadData]);
// ===== 분개 수정 완료 =====
const handleJournalEditSuccess = useCallback(() => {
setJournalEditTarget(null);
loadData();
invalidateDashboard('journalEntry');
}, [loadData]);
// ===== 합계 계산 =====

View File

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

View File

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

View File

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

View File

@@ -44,10 +44,8 @@ import type {
import {
getGiftCertificates,
getGiftCertificateSummary,
deleteGiftCertificate,
} from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import { applyFilters, enumFilter } from '@/lib/utils/search';
import { useDateRange } from '@/hooks';
@@ -55,12 +53,12 @@ import { useDateRange } from '@/hooks';
// ===== 테이블 컬럼 정의 (체크박스/No. 제외) =====
const tableColumns = [
{ key: 'rowNumber', label: '번호', className: 'text-center' },
{ key: 'serialNumber', label: '일련번호', sortable: true, copyable: true },
{ key: 'name', label: '상품권명', sortable: true, copyable: true },
{ key: 'faceValue', label: '액면가', className: 'text-right', sortable: true, copyable: true },
{ key: 'purchaseDate', label: '구입일', className: 'text-center', sortable: true, copyable: true },
{ key: 'usedDate', label: '사용일', className: 'text-center', sortable: true, copyable: true },
{ key: 'entertainmentExpense', label: '접대비', className: 'text-center', sortable: true, copyable: true },
{ key: 'serialNumber', label: '일련번호', sortable: true },
{ key: 'name', label: '상품권명', sortable: true },
{ key: 'faceValue', label: '액면가', className: 'text-right', sortable: true },
{ key: 'purchaseDate', label: '구입일', className: 'text-center', sortable: true },
{ key: 'usedDate', label: '사용일', className: 'text-center', sortable: true },
{ key: 'entertainmentExpense', label: '접대비', className: 'text-center', sortable: true },
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
];
@@ -87,7 +85,7 @@ export function GiftCertificateManagement() {
// 필터 상태
const [statusFilter, setStatusFilter] = useState('all');
const [entertainmentFilter, setEntertainmentFilter] = useState('all');
const [, setSearchQuery] = useState('');
const [searchQuery, setSearchQuery] = useState('');
// 날짜 범위
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth');
@@ -125,7 +123,7 @@ export function GiftCertificateManagement() {
// ===== 핸들러 =====
const handleRowClick = useCallback((item: GiftCertificateRecord) => {
router.push(`/accounting/gift-certificates?mode=view&id=${item.id}`);
router.push(`/accounting/gift-certificates?mode=edit&id=${item.id}`);
}, [router]);
const handleCreate = useCallback(() => {
@@ -147,14 +145,6 @@ export function GiftCertificateManagement() {
data,
totalCount: data.length,
}),
deleteItem: async (id: string) => {
const result = await deleteGiftCertificate(id);
if (result.success) {
invalidateDashboard('giftCertificate');
await loadData();
}
return { success: result.success, error: result.error };
},
},
columns: tableColumns,
@@ -369,7 +359,7 @@ export function GiftCertificateManagement() {
);
},
}),
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate, loadData]
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate]
);
return (

View File

@@ -1,12 +1,14 @@
'use client';
import { useState, useCallback, useEffect } from 'react';
import { useState, useCallback, useMemo, useEffect } from 'react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { DatePicker } from '@/components/ui/date-picker';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import {
Select,
SelectContent,
@@ -30,7 +32,6 @@ import {
deletePurchase,
} from './actions';
import { getClients } from '../VendorManagement/actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { toast } from 'sonner';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
@@ -62,7 +63,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
// ===== 로딩 상태 =====
const [isLoading, setIsLoading] = useState(true);
const [, setIsSaving] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// ===== 거래처 목록 =====
const [clients, setClients] = useState<ClientOption[]>([]);
@@ -71,20 +72,20 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const [purchaseNo, setPurchaseNo] = useState('');
const [purchaseDate, setPurchaseDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [vendorId, setVendorId] = useState('');
const [, setVendorName] = useState('');
const [vendorName, setVendorName] = useState('');
// purchaseType 삭제됨 (기획서 P.109)
const [items, setItems] = useState<PurchaseItem[]>([createEmptyItem()]);
const [taxInvoiceReceived, setTaxInvoiceReceived] = useState(false);
// ===== 문서 관련 상태 (sourceDocument는 API에서 아직 미지원) =====
const [sourceDocument, setSourceDocument] = useState<PurchaseRecord['sourceDocument']>(undefined);
const [, setWithdrawalAccount] = useState<PurchaseRecord['withdrawalAccount']>(undefined);
const [, setCreatedAt] = useState('');
const [withdrawalAccount, setWithdrawalAccount] = useState<PurchaseRecord['withdrawalAccount']>(undefined);
const [createdAt, setCreatedAt] = useState('');
// ===== 문서 열람 모달 상태 =====
const [documentModalOpen, setDocumentModalOpen] = useState(false);
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | null>(null);
const [, setIsModalLoading] = useState(false);
const [isModalLoading, setIsModalLoading] = useState(false);
const [approvalId, setApprovalId] = useState<string | undefined>(undefined);
// ===== 품목 관리 (공통 훅) =====
@@ -259,7 +260,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
}
if (result?.success) {
invalidateDashboard('purchase');
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
return { success: true };
} else {
@@ -282,7 +282,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
const result = await deletePurchase(purchaseId);
if (result.success) {
invalidateDashboard('purchase');
toast.success('매입이 삭제되었습니다.');
return { success: true };
} else {

View File

@@ -21,8 +21,10 @@ import { toast } from 'sonner';
import {
Receipt,
Save,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
// Badge, getPresetStyle removed (매입유형/연결문서 컬럼 삭제)
import { Switch } from '@/components/ui/switch';
@@ -58,18 +60,17 @@ import {
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
} from './types';
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { formatNumber } from '@/lib/utils/amount';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
{ key: 'purchaseNo', label: '매입번호', sortable: true, copyable: true },
{ key: 'purchaseDate', label: '매입일', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true, copyable: true },
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true, copyable: true },
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true, copyable: true },
{ key: 'purchaseNo', label: '매입번호', sortable: true },
{ key: 'purchaseDate', label: '매입일', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
{ key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' },
];
@@ -171,12 +172,12 @@ export function PurchaseManagement() {
], [vendorOptions]);
// 필터 변경 핸들러
const _handleFilterChange = useCallback((key: string, value: string | string[]) => {
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
setFilterValues(prev => ({ ...prev, [key]: value }));
}, []);
// 필터 초기화 핸들러
const _handleFilterReset = useCallback(() => {
const handleFilterReset = useCallback(() => {
setFilterValues({
vendor: 'all',
taxInvoiceReceived: 'all',
@@ -252,7 +253,6 @@ export function PurchaseManagement() {
deleteItem: async (id: string) => {
const result = await deletePurchase(id);
if (result.success) {
invalidateDashboard('purchase');
setPurchaseData(prev => prev.filter(item => item.id !== id));
toast.success('매입이 삭제되었습니다.');
}

View File

@@ -5,7 +5,7 @@ import { executeServerAction, type ActionResult } from '@/lib/api/execute-server
import { buildApiUrl } from '@/lib/api/query-params';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { cookies } from 'next/headers';
import type { VendorReceivables, CategoryType, ReceivablesListResponse, MemoUpdateRequest } from './types';
import type { VendorReceivables, CategoryType, MonthlyAmount, ReceivablesListResponse, MemoUpdateRequest } from './types';
// ===== API 응답 타입 =====
interface CategoryAmountApi {

View File

@@ -5,6 +5,7 @@ import { Download, FileText, Save, Loader2, RefreshCw, ChevronDown, ChevronUp }
import { formatNumber } from '@/lib/utils/amount';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@@ -65,7 +66,7 @@ const generateYearOptions = (): Array<{ value: number; label: string }> => {
};
// ===== 카테고리 순서 (메모 제외) =====
const _categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
export function ReceivablesStatus({ highlightVendorId, initialData, initialSummary }: ReceivablesStatusProps) {
const { canExport } = usePermission();
@@ -81,7 +82,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
const [data, setData] = useState<VendorReceivables[]>(initialData?.items || []);
const [originalOverdueMap, setOriginalOverdueMap] = useState<Map<string, boolean>>(new Map());
const [originalMemoMap, setOriginalMemoMap] = useState<Map<string, string>>(new Map());
const [, setSummary] = useState(initialSummary || {
const [summary, setSummary] = useState(initialSummary || {
totalCarryForward: 0,
totalSales: 0,
totalDeposits: 0,

View File

@@ -34,7 +34,6 @@ import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTa
import { salesConfig } from './salesConfig';
import type { SalesRecord, SalesItem } from './types';
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { toast } from 'sonner';
import { getClients } from '../VendorManagement/actions';
@@ -68,7 +67,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
// ===== 로딩 상태 =====
const [isLoading, setIsLoading] = useState(true);
const [, setIsSaving] = useState(false);
const [isSaving, setIsSaving] = useState(false);
// ===== 거래처 목록 =====
const [clients, setClients] = useState<ClientOption[]>([]);
@@ -77,7 +76,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const [salesNo, setSalesNo] = useState('');
const [salesDate, setSalesDate] = useState(format(new Date(), 'yyyy-MM-dd'));
const [vendorId, setVendorId] = useState('');
const [, setVendorName] = useState('');
const [vendorName, setVendorName] = useState('');
const [items, setItems] = useState<SalesItem[]>([createEmptyItem()]);
const [taxInvoiceIssued, setTaxInvoiceIssued] = useState(false);
const [transactionStatementIssued, setTransactionStatementIssued] = useState(false);
@@ -174,7 +173,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
}
if (result?.success) {
invalidateDashboard('sales');
toast.success(isNewMode ? '매출이 등록되었습니다.' : '매출이 수정되었습니다.');
return { success: true };
} else {
@@ -197,7 +195,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
const result = await deleteSale(salesId);
if (result.success) {
invalidateDashboard('sales');
toast.success('매출이 삭제되었습니다.');
return { success: true };
} else {

View File

@@ -20,8 +20,10 @@ import { toast } from 'sonner';
import {
Receipt,
Save,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import {
@@ -71,12 +73,12 @@ import { applyFilters, enumFilter } from '@/lib/utils/search';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'salesNo', label: '매출번호', sortable: true, copyable: true },
{ key: 'salesDate', label: '매출일', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
{ key: 'totalSupplyAmount', label: '공급가액', className: 'text-right', sortable: true, copyable: true },
{ key: 'totalVat', label: '부가세', className: 'text-right', sortable: true, copyable: true },
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true, copyable: true },
{ key: 'salesNo', label: '매출번호', sortable: true },
{ key: 'salesDate', label: '매출일', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
{ key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' },
{ key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' },
];
@@ -195,7 +197,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
if ((!initialData || initialData.length === 0) && !isLoading) {
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// ===== 페이지 변경 =====

View File

@@ -224,7 +224,7 @@ export const API_STATUS_MAP: Record<string, SalesStatus> = {
// API 응답 → 프론트엔드 타입 변환
export function transformApiToFrontend(apiData: SaleApiData): SalesRecord {
// 수주(Order)의 품목(items)을 매출 품목으로 변환
const items: SalesItem[] = (apiData.order?.items ?? []).map((item, _index) => ({
const items: SalesItem[] = (apiData.order?.items ?? []).map((item, index) => ({
id: String(item.id),
itemName: item.item_name ?? '',
quantity: parseFloat(String(item.quantity)) || 0,

View File

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

View File

@@ -42,6 +42,7 @@ import {
SORT_BY_OPTIONS,
SORT_ORDER_OPTIONS,
TAX_INVOICE_STATUS_MAP,
createEmptyBusinessEntity,
} from './types';
import type {
TaxInvoiceRecord,

View File

@@ -53,11 +53,11 @@ import {
updateJournalEntry,
deleteJournalEntry,
} from './actions';
import { AccountSubjectSelect } from '@/components/accounting/common';
import type { TaxInvoiceMgmtRecord, JournalEntryRow, JournalSide } from './types';
import {
TAB_OPTIONS,
JOURNAL_SIDE_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
interface JournalEntryModalProps {
@@ -127,12 +127,12 @@ export function JournalEntryModal({
}, [open, invoice]);
// 행 추가
const _handleAddRow = useCallback(() => {
const handleAddRow = useCallback(() => {
setRows((prev) => [...prev, createEmptyRow()]);
}, []);
// 행 삭제
const _handleRemoveRow = useCallback((rowId: string) => {
const handleRemoveRow = useCallback((rowId: string) => {
setRows((prev) => {
if (prev.length <= 1) return prev;
return prev.filter((r) => r.id !== rowId);
@@ -288,14 +288,25 @@ export function JournalEntryModal({
</Select>
</TableCell>
<TableCell className="p-1">
<AccountSubjectSelect
<Select
value={row.accountSubject}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubject', v)
}
placeholder="선택"
size="sm"
/>
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.filter((o) => o.value).map(
(opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
)
)}
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input

View File

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

View File

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

View File

@@ -103,18 +103,18 @@ const excelColumns: ExcelColumn<TaxInvoiceMgmtRecord & Record<string, unknown>>[
// ===== 테이블 컬럼 =====
const tableColumns = [
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true, copyable: true },
{ key: 'issueDate', label: '발급일자', className: 'text-center', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
{ key: 'vendorBusinessNumber', label: '사업자번호\n(주민번호)', className: 'text-center', sortable: true, copyable: true },
{ key: 'taxType', label: '과세형태', className: 'text-center', sortable: true, copyable: true },
{ key: 'itemName', label: '품목', sortable: true, copyable: true },
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true, copyable: true },
{ key: 'taxAmount', label: '세액', className: 'text-right', sortable: true, copyable: true },
{ key: 'totalAmount', label: '합계', className: 'text-right', sortable: true, copyable: true },
{ key: 'receiptType', label: '영수청구', className: 'text-center', sortable: true, copyable: true },
{ key: 'documentType', label: '문서형태', className: 'text-center', sortable: true, copyable: true },
{ key: 'issueType', label: '발급형태', className: 'text-center', sortable: true, copyable: true },
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true },
{ key: 'issueDate', label: '발급일자', className: 'text-center', sortable: true },
{ key: 'vendorName', label: '거래처', sortable: true },
{ key: 'vendorBusinessNumber', label: '사업자번호\n(주민번호)', className: 'text-center', sortable: true },
{ key: 'taxType', label: '과세형태', className: 'text-center', sortable: true },
{ key: 'itemName', label: '품목', sortable: true },
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
{ key: 'taxAmount', label: '세액', className: 'text-right', sortable: true },
{ key: 'totalAmount', label: '합계', className: 'text-right', sortable: true },
{ key: 'receiptType', label: '영수청구', className: 'text-center', sortable: true },
{ key: 'documentType', label: '문서형태', className: 'text-center', sortable: true },
{ key: 'issueType', label: '발급형태', className: 'text-center', sortable: true },
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
{ key: 'journal', label: '분개', className: 'text-center w-[80px]' },
];

View File

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

View File

@@ -36,12 +36,12 @@ import { usePermission } from '@/hooks/usePermission';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
{ key: 'vendorName', label: '거래처명', sortable: true, copyable: true },
{ key: 'carryoverBalance', label: '이월잔액', className: 'text-right w-[120px]', sortable: true, copyable: true },
{ key: 'sales', label: '매출', className: 'text-right w-[120px]', sortable: true, copyable: true },
{ key: 'collection', label: '수금', className: 'text-right w-[120px]', sortable: true, copyable: true },
{ key: 'balance', label: '잔액', className: 'text-right w-[120px]', sortable: true, copyable: true },
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처명', sortable: true },
{ key: 'carryoverBalance', label: '이월잔액', className: 'text-right w-[120px]', sortable: true },
{ key: 'sales', label: '매출', className: 'text-right w-[120px]', sortable: true },
{ key: 'collection', label: '수금', className: 'text-right w-[120px]', sortable: true },
{ key: 'balance', label: '잔액', className: 'text-right w-[120px]', sortable: true },
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true },
];
// ===== 엑셀 컬럼 정의 =====

View File

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

View File

@@ -42,9 +42,15 @@ import {
type RowClickHandlers,
} from '@/components/templates/UniversalListPage';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import { deleteClient } from './actions';
import type {
Vendor,
VendorCategory,
CreditRating,
TransactionGrade,
BadDebtStatus,
VendorStatus,
SortOption,
} from './types';
import {
@@ -69,7 +75,7 @@ interface VendorManagementClientProps {
initialTotal: number;
}
export function VendorManagementClient({ initialData, initialTotal: _initialTotal }: VendorManagementClientProps) {
export function VendorManagementClient({ initialData, initialTotal }: VendorManagementClientProps) {
const router = useRouter();
// ===== 상태 관리 =====
@@ -197,13 +203,13 @@ export function VendorManagementClient({ initialData, initialTotal: _initialTota
// ===== 테이블 컬럼 =====
const tableColumns: TableColumn[] = useMemo(() => [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'category', label: '구분', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처명', sortable: true, copyable: true },
{ key: 'purchasePaymentDay', label: '매입 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'salesPaymentDay', label: '매출 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'category', label: '구분', className: 'text-center w-[100px]', sortable: true },
{ key: 'vendorName', label: '거래처명', sortable: true },
{ key: 'purchasePaymentDay', label: '매입 결제일', className: 'text-center w-[100px]', sortable: true },
{ key: 'salesPaymentDay', label: '매출 결제일', className: 'text-center w-[100px]', sortable: true },
{ key: 'creditRating', label: '신용등급', className: 'text-center w-[90px]', sortable: true },
{ key: 'transactionGrade', label: '거래등급', className: 'text-center w-[100px]', sortable: true },
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]', sortable: true, copyable: true },
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]', sortable: true },
{ key: 'badDebtStatus', label: '악성채권', className: 'text-center w-[90px]', sortable: true },
{ key: 'status', label: '상태', className: 'text-center w-[80px]', sortable: true },
{ key: 'actions', label: '작업', className: 'text-center w-[150px]' },

View File

@@ -11,7 +11,6 @@
*/
import { useState, useMemo, useCallback } from 'react';
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import { formatNumber } from '@/lib/utils/amount';
@@ -56,13 +55,13 @@ import { toast } from 'sonner';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
{ key: 'category', label: '구분', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'vendorName', label: '거래처명', sortable: true, copyable: true },
{ key: 'purchasePaymentDay', label: '매입 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'salesPaymentDay', label: '매출 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
{ key: 'category', label: '구분', className: 'text-center w-[100px]', sortable: true },
{ key: 'vendorName', label: '거래처명', sortable: true },
{ key: 'purchasePaymentDay', label: '매입 결제일', className: 'text-center w-[100px]', sortable: true },
{ key: 'salesPaymentDay', label: '매출 결제일', className: 'text-center w-[100px]', sortable: true },
{ key: 'creditRating', label: '신용등급', className: 'text-center w-[90px]', sortable: true },
{ key: 'transactionGrade', label: '거래등급', className: 'text-center w-[100px]', sortable: true },
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]', sortable: true, copyable: true },
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]', sortable: true },
{ key: 'badDebtStatus', label: '악성채권', className: 'text-center w-[90px]', sortable: true },
{ key: 'status', label: '상태', className: 'text-center w-[80px]', sortable: true },
];
@@ -73,7 +72,7 @@ interface VendorManagementProps {
initialTotal: number;
}
export function VendorManagement({ initialData, initialTotal: _initialTotal }: VendorManagementProps) {
export function VendorManagement({ initialData, initialTotal }: VendorManagementProps) {
const router = useRouter();
// ===== 날짜 범위 상태 =====
@@ -130,7 +129,6 @@ export function VendorManagement({ initialData, initialTotal: _initialTotal }: V
deleteItem: async (id: string) => {
const result = await deleteClient(id);
if (result.success) {
invalidateDashboard('client');
toast.success('거래처가 삭제되었습니다.');
}
return { success: result.success, error: result.error };

View File

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

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