17 Commits

Author SHA1 Message Date
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
482 changed files with 10306 additions and 19917 deletions

View File

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

View File

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

130
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,78 +8,21 @@ 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 {
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
}
slackSend channel: '#deploy_react', color: '#439FE0', tokenCredentialId: 'slack-token',
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
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: '#product_infra', 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: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
}
}
}

BIN
claudedocs/architecture/.DS_Store vendored Normal file

Binary file not shown.

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 --turbo",
"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

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

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);

File diff suppressed because it is too large Load Diff

View File

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

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

@@ -6,7 +6,6 @@ import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/ty
export const MOCK_WORK_ORDER: WorkOrder = {
id: 'wo-1',
orderNo: 'KD-WO-240924-01',
productCode: 'WY-SC780',
productName: '스크린 셔터 (표준형)',
processCode: 'screen',
processName: 'screen',
@@ -98,14 +97,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 +120,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 +133,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 +146,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 +156,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 +164,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

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

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

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 응답 타입 =====
@@ -72,9 +72,8 @@ function mapApiStatusToFrontend(apiStatus: string): CollectionStatus {
switch (apiStatus) {
case 'collecting': return 'collecting';
case 'legal_action': return 'legalAction';
case 'recovered':
case 'bad_debt':
case 'collection_end': return 'collectionEnd';
case 'recovered': return 'recovered';
case 'bad_debt': return 'badDebt';
default: return 'collecting';
}
}
@@ -83,7 +82,8 @@ function mapFrontendStatusToApi(status: CollectionStatus): string {
switch (status) {
case 'collecting': return 'collecting';
case 'legalAction': return 'legal_action';
case 'collectionEnd': return 'collection_end';
case 'recovered': return 'recovered';
case 'badDebt': return 'bad_debt';
default: return 'collecting';
}
}

View File

@@ -14,10 +14,8 @@ 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';
import { AlertTriangle } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
@@ -51,14 +49,13 @@ 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]' },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
];
// ===== Props 타입 정의 =====
@@ -68,7 +65,8 @@ interface BadDebtCollectionProps {
total_amount: number;
collecting_amount: number;
legal_action_amount: number;
collection_end_amount: number;
recovered_amount: number;
bad_debt_amount: number;
} | null;
}
@@ -134,7 +132,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
totalAmount: initialSummary.total_amount,
collectingAmount: initialSummary.collecting_amount,
legalActionAmount: initialSummary.legal_action_amount,
collectionEndAmount: initialSummary.collection_end_amount,
recoveredAmount: initialSummary.recovered_amount,
};
}
@@ -146,11 +144,11 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
const legalActionAmount = data
.filter((d) => d.status === 'legalAction')
.reduce((sum, d) => sum + d.debtAmount, 0);
const collectionEndAmount = data
.filter((d) => d.status === 'collectionEnd')
const recoveredAmount = data
.filter((d) => d.status === 'recovered')
.reduce((sum, d) => sum + d.debtAmount, 0);
return { totalAmount, collectingAmount, legalActionAmount, collectionEndAmount };
return { totalAmount, collectingAmount, legalActionAmount, recoveredAmount };
}, [data, initialSummary]);
// ===== UniversalListPage Config =====
@@ -177,7 +175,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 };
@@ -338,7 +335,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
},
{
label: '회수완료',
value: `${formatNumber(statsData.collectionEndAmount)}`,
value: `${formatNumber(statsData.recoveredAmount)}`,
icon: AlertTriangle,
iconColor: 'text-green-500',
},
@@ -393,27 +390,6 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
disabled={isPending}
/>
</TableCell>
{/* 작업 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=edit`)}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-500 hover:text-red-700"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
),

View File

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

View File

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

View File

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

View File

@@ -10,19 +10,20 @@
* - tableHeaderActions: 거래처, 구분, 상태 필터
*/
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { formatNumber } from '@/lib/utils/amount';
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,
@@ -31,6 +32,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Label } from '@/components/ui/label';
import {
UniversalListPage,
type UniversalListConfig,
@@ -42,6 +45,7 @@ import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { toast } from 'sonner';
import type {
BillRecord,
BillType,
BillStatus,
SortOption,
} from './types';
@@ -50,8 +54,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 +83,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');
@@ -129,16 +148,6 @@ export function BillManagementClient({
}
}, [searchQuery, billTypeFilter, statusFilter, vendorFilter, startDate, endDate, sortOption, itemsPerPage]);
// ===== 필터 변경 시 자동 재조회 =====
const isInitialMount = useRef(true);
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
loadData(1);
}, [loadData]);
// ===== 체크박스 핸들러 =====
const toggleSelection = useCallback((id: string) => {
setSelectedItems(prev => {
@@ -172,13 +181,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 +273,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 +289,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 +326,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: '이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
},
// 테이블 컬럼
@@ -365,8 +348,32 @@ export function BillManagementClient({
);
},
// 모바일 필터 설정 (tableHeaderActions와 중복 방지를 위해 비워둠)
filterConfig: [],
// 모바일 필터 설정
filterConfig: [
{
key: 'vendorFilter',
label: '거래처',
type: 'single',
options: vendorOptions.filter(o => o.value !== 'all'),
},
{
key: 'billType',
label: '구분',
type: 'single',
options: BILL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
},
{
key: 'status',
label: '상태',
type: 'single',
options: BILL_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all'),
},
],
initialFilters: {
vendorFilter: vendorFilter,
billType: billTypeFilter,
status: statusFilter,
},
filterTitle: '어음 필터',
// 날짜 선택기
@@ -385,28 +392,42 @@ 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" />
// 헤더 액션: 수취/발행 라디오 + 상태 선택 + 저장
// 모바일: 라디오/상태필터는 숨기고 저장만 표시 (filterConfig 바텀시트와 중복 방지)
// 데스크톱: 모두 표시
headerActions: () => (
<div className="flex items-center gap-3" style={{ display: 'flex' }}>
<div className="hidden xl:flex items-center gap-3">
<RadioGroup
value={billTypeFilter}
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
className="flex items-center gap-3"
>
<div className="flex items-center space-x-1">
<RadioGroupItem value="received" id="received" />
<Label htmlFor="received" className="cursor-pointer text-sm whitespace-nowrap"></Label>
</div>
<div className="flex items-center space-x-1">
<RadioGroupItem value="issued" id="issued" />
<Label htmlFor="issued" className="cursor-pointer text-sm whitespace-nowrap"></Label>
</div>
</RadioGroup>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="min-w-[100px] w-auto">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button onClick={handleSave} size="sm" disabled={isLoading}>
<Save className="h-4 w-4 mr-1" />
</Button>
</div>
),
@@ -427,7 +448,7 @@ export function BillManagementClient({
</SelectContent>
</Select>
<Select value={billTypeFilter} onValueChange={setBillTypeFilter}>
<Select value={billTypeFilter} onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}>
<SelectTrigger className="min-w-[100px] w-auto">
<SelectValue placeholder="구분" />
</SelectTrigger>
@@ -440,7 +461,7 @@ export function BillManagementClient({
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); loadData(1); }}>
<SelectTrigger className="min-w-[110px] w-auto">
<SelectValue placeholder="보관중" />
</SelectTrigger>
@@ -472,10 +493,7 @@ export function BillManagementClient({
isLoading,
router,
loadData,
currentPage,
handleStatusChange,
statusChangeOptions,
targetStatus,
handleSave,
renderTableRow,
renderMobileCard,
]
@@ -501,6 +519,14 @@ export function BillManagementClient({
}}
/>
<DeleteConfirmDialog
open={deleteDialog.single.isOpen}
onOpenChange={deleteDialog.single.onOpenChange}
onConfirm={deleteDialog.single.confirm}
title="어음 삭제"
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
loading={deleteDialog.isPending}
/>
</>
);
}

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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="상품권명"

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