Compare commits
18 Commits
7a8d946960
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c3266e5d3f | |||
| fdb1230c69 | |||
| e871f6232f | |||
| 2f00eac0f0 | |||
| dcaca59685 | |||
| 81373281ea | |||
| 72cf5d86a2 | |||
| a4f99ae339 | |||
| 9ad4c8ee9f | |||
| 04f2a8a74c | |||
| 8b6da749a9 | |||
| c150d80725 | |||
| f9eea0c950 | |||
|
|
2a2a356f58 | ||
|
|
181352d7a9 | ||
|
|
1691337f7d | ||
|
|
4e179d2eca | ||
|
|
db84d6796b |
@@ -107,10 +107,3 @@ 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:
|
||||
|
||||
17
CLAUDE.md
17
CLAUDE.md
@@ -326,16 +326,19 @@ const [data, setData] = useState(() => {
|
||||
|
||||
---
|
||||
|
||||
## Backend API Analysis Policy
|
||||
## Backend API Policy
|
||||
**Priority**: 🟡
|
||||
|
||||
- Backend API 코드는 **분석만**, 직접 수정 안 함
|
||||
- 수정 필요 시 백엔드 요청 문서로 정리:
|
||||
- **신규 API 생성 금지**: 새로운 엔드포인트/컨트롤러 생성은 직접 하지 않음 → 요청 문서로 정리
|
||||
- **기존 API 수정/추가 가능**: 이미 존재하는 API의 수정, 필드 추가, 로직 변경은 직접 수행 가능
|
||||
- 백엔드 경로: `sam_project/sam-api/sam-api` (PHP Laravel)
|
||||
- 수정 시 기존 코드 패턴(Service-First, 기존 응답 구조) 준수
|
||||
- 신규 API가 필요한 경우 요청 문서로 정리:
|
||||
```markdown
|
||||
## 백엔드 API 수정 요청
|
||||
### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX)
|
||||
### 현재 문제: [설명]
|
||||
### 수정 요청: [내용]
|
||||
## 백엔드 API 신규 요청
|
||||
### 엔드포인트: [HTTP METHOD /api/v1/path]
|
||||
### 목적: [설명]
|
||||
### 요청/응답 구조: [내용]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
130
Jenkinsfile
vendored
130
Jenkinsfile
vendored
@@ -1,6 +1,12 @@
|
||||
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()
|
||||
}
|
||||
@@ -8,21 +14,78 @@ 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: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
|
||||
slackSend channel: '#deploy_react', color: '#439FE0', tokenCredentialId: 'slack-token',
|
||||
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
|
||||
stage('Prepare Env') {
|
||||
when { expression { params.ACTION == 'deploy' } }
|
||||
steps {
|
||||
script {
|
||||
if (env.BRANCH_NAME == 'main') {
|
||||
@@ -37,16 +100,23 @@ 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 { branch 'develop' }
|
||||
when {
|
||||
allOf {
|
||||
branch 'develop'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
@@ -63,16 +133,21 @@ pipeline {
|
||||
|
||||
// ── main → 운영서버 Stage 배포 ──
|
||||
stage('Deploy Stage') {
|
||||
when { branch 'main' }
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
|
||||
rsync -az --delete \
|
||||
.next package.json next.config.ts public node_modules \
|
||||
${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 '
|
||||
${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} '
|
||||
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
|
||||
@@ -97,7 +172,12 @@ pipeline {
|
||||
|
||||
// ── main → Production 재빌드 (운영 환경변수) ──
|
||||
stage('Rebuild for Production') {
|
||||
when { branch 'main' }
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production"
|
||||
sh 'npm run build'
|
||||
@@ -106,16 +186,21 @@ pipeline {
|
||||
|
||||
// ── main → 운영서버 Production 배포 ──
|
||||
stage('Deploy Production') {
|
||||
when { branch 'main' }
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
|
||||
rsync -az --delete \
|
||||
.next package.json next.config.ts public node_modules \
|
||||
${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 '
|
||||
${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} '
|
||||
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
|
||||
@@ -128,12 +213,23 @@ pipeline {
|
||||
|
||||
post {
|
||||
success {
|
||||
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}>"
|
||||
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}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
failure {
|
||||
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}>"
|
||||
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}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
claudedocs/architecture/.DS_Store
vendored
BIN
claudedocs/architecture/.DS_Store
vendored
Binary file not shown.
@@ -15,6 +15,7 @@ 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
|
||||
],
|
||||
},
|
||||
@@ -76,9 +77,38 @@ 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: {
|
||||
@@ -95,7 +125,9 @@ const eslintConfig = [
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_"
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_"
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ 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: [
|
||||
@@ -28,6 +29,9 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
// Capacitor 패키지는 모바일 앱 전용 - 웹 빌드에서 제외
|
||||
webpack: (config, { isServer }) => {
|
||||
// macOS 26 호환성: webpack 캐시 비활성화 (rename ENOENT 방지)
|
||||
config.cache = false;
|
||||
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"dev": "node scripts/validate-next-cache.mjs && NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' next dev",
|
||||
"build": "next build",
|
||||
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
|
||||
"start": "next start -H 0.0.0.0",
|
||||
|
||||
46
scripts/patch-json-parse.cjs
Normal file
46
scripts/patch-json-parse.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* JSON.parse 글로벌 패치 - macOS 26 파일시스템 손상 대응
|
||||
*
|
||||
* macOS 26에서 atomic write(tmp + rename)가 실패하면
|
||||
* .next/prerender-manifest.json 등의 파일에 데이터가 중복 기록됨.
|
||||
* 이로 인해 "Unexpected non-whitespace character after JSON at position N" 발생.
|
||||
*
|
||||
* 이 패치는 JSON.parse 실패 시 유효한 JSON 부분만 추출하여 자동 복구.
|
||||
* NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' 로 로드.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const originalParse = JSON.parse;
|
||||
|
||||
JSON.parse = function patchedJsonParse(text, reviver) {
|
||||
try {
|
||||
return originalParse.call(this, text, reviver);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError && typeof text === 'string') {
|
||||
// "Unexpected non-whitespace character after JSON at position N"
|
||||
// → position N까지가 유효한 JSON
|
||||
const match = e.message.match(/after JSON at position\s+(\d+)/);
|
||||
if (match) {
|
||||
const pos = parseInt(match[1], 10);
|
||||
if (pos > 0) {
|
||||
try {
|
||||
const result = originalParse.call(this, text.substring(0, pos), reviver);
|
||||
// 한 번만 경고 (같은 position이면 반복 출력 방지)
|
||||
if (!patchedJsonParse._warned) patchedJsonParse._warned = new Set();
|
||||
const key = pos + ':' + text.length;
|
||||
if (!patchedJsonParse._warned.has(key)) {
|
||||
patchedJsonParse._warned.add(key);
|
||||
console.warn(
|
||||
`[patch-json-parse] macOS 파일 손상 자동 복구 (position ${pos}, total ${text.length} bytes)`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
// truncation으로도 실패하면 원래 에러 throw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
49
scripts/validate-next-cache.mjs
Normal file
49
scripts/validate-next-cache.mjs
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* .next 빌드 캐시 무결성 검증
|
||||
*
|
||||
* macOS 26 파일시스템 이슈로 .next/ 내 JSON 파일이 손상될 수 있음.
|
||||
* (atomic write 실패 → 데이터 중복 기록)
|
||||
* dev 서버 시작 전 자동 검증하여 손상 시 .next 삭제.
|
||||
*/
|
||||
import { readFileSync, rmSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const NEXT_DIR = '.next';
|
||||
|
||||
if (!existsSync(NEXT_DIR)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const jsonFiles = [];
|
||||
try {
|
||||
// .next/ 루트의 JSON 파일들
|
||||
for (const f of readdirSync(NEXT_DIR)) {
|
||||
if (f.endsWith('.json')) jsonFiles.push(join(NEXT_DIR, f));
|
||||
}
|
||||
// .next/server/ 의 JSON 파일들
|
||||
const serverDir = join(NEXT_DIR, 'server');
|
||||
if (existsSync(serverDir)) {
|
||||
for (const f of readdirSync(serverDir)) {
|
||||
if (f.endsWith('.json')) jsonFiles.push(join(serverDir, f));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 디렉토리 읽기 실패 시 무시
|
||||
}
|
||||
|
||||
let corrupted = false;
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ 손상된 캐시 발견: ${file}`);
|
||||
console.warn(` ${e.message}`);
|
||||
corrupted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (corrupted) {
|
||||
console.warn('🗑️ .next 캐시를 삭제하고 재빌드합니다...');
|
||||
rmSync(NEXT_DIR, { recursive: true, force: true });
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export default function BadDebtCollectionPage() {
|
||||
return (
|
||||
<BadDebtCollection
|
||||
initialData={data}
|
||||
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; recovered_amount: number; bad_debt_amount: number; } | null}
|
||||
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; collection_end_amount: number; } | null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function VendorsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'new') {
|
||||
getClients({ size: 100 })
|
||||
getClients({ size: 1000 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
|
||||
@@ -103,7 +103,7 @@ function BoardListContent({ boardCode }: { boardCode: string }) {
|
||||
|
||||
// 게시글 목록
|
||||
const [posts, setPosts] = useState<BoardPost[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [, 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]' },
|
||||
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
||||
{ key: 'views', label: '조회수', className: 'w-[80px] 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: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true },
|
||||
], []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
deleteDynamicBoardPost,
|
||||
} from '@/components/board/DynamicBoard/actions';
|
||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||
import { transformApiToComment, type CommentApiData } from '@/components/customer-center/shared/types';
|
||||
import { transformApiToComment } from '@/components/customer-center/shared/types';
|
||||
import type { PostApiData } from '@/components/customer-center/shared/types';
|
||||
import { sanitizeHTML } from '@/lib/sanitize';
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) {
|
||||
|
||||
// 게시글 목록
|
||||
const [posts, setPosts] = useState<BoardPost[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [, 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]' },
|
||||
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
||||
{ key: 'views', label: '조회수', className: 'w-[80px] 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: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true },
|
||||
], []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { CategoryManagement } from '@/components/business/construction/category-management';
|
||||
|
||||
export default function CategoriesPage() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
1322
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
1322
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,14 @@ import { DataTable } from '@/components/organisms/DataTable';
|
||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal';
|
||||
// UI - 추가
|
||||
import { VisuallyHidden } from '@/components/ui/visually-hidden';
|
||||
import { DateRangePicker } from '@/components/ui/date-range-picker';
|
||||
import { DateTimePicker } from '@/components/ui/date-time-picker';
|
||||
// Molecules - 추가
|
||||
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
||||
import { GenericCRUDDialog } from '@/components/molecules/GenericCRUDDialog';
|
||||
import { ReorderButtons } from '@/components/molecules/ReorderButtons';
|
||||
// Organisms - 추가
|
||||
import { LineItemsTable } from '@/components/organisms/LineItemsTable/LineItemsTable';
|
||||
// Lucide icons for demos
|
||||
import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react';
|
||||
|
||||
@@ -339,6 +347,89 @@ function SearchableSelectionDemo() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── 추가 Demo Wrappers ──
|
||||
|
||||
function DateRangePickerDemo() {
|
||||
const [start, setStart] = useState<string | undefined>();
|
||||
const [end, setEnd] = useState<string | undefined>();
|
||||
return (
|
||||
<div className="max-w-sm">
|
||||
<DateRangePicker startDate={start} endDate={end} onStartDateChange={setStart} onEndDateChange={setEnd} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DateTimePickerDemo() {
|
||||
const [v, setV] = useState<string | undefined>();
|
||||
return (
|
||||
<div className="max-w-sm">
|
||||
<DateTimePicker value={v} onChange={setV} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnSettingsPopoverDemo() {
|
||||
const [cols, setCols] = useState([
|
||||
{ key: 'name', label: '품목명', visible: true, locked: true },
|
||||
{ key: 'spec', label: '규격', visible: true, locked: false },
|
||||
{ key: 'qty', label: '수량', visible: true, locked: false },
|
||||
{ key: 'price', label: '단가', visible: false, locked: false },
|
||||
{ key: 'note', label: '비고', visible: false, locked: false },
|
||||
]);
|
||||
return (
|
||||
<ColumnSettingsPopover
|
||||
columns={cols}
|
||||
onToggle={(key) => setCols((prev) => prev.map((c) => (c.key === key && !c.locked ? { ...c, visible: !c.visible } : c)))}
|
||||
onReset={() => setCols((prev) => prev.map((c) => ({ ...c, visible: true })))}
|
||||
hasHiddenColumns={cols.some((c) => !c.visible)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GenericCRUDDialogDemo() {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>CRUD 다이얼로그 열기</Button>
|
||||
<GenericCRUDDialog
|
||||
isOpen={open}
|
||||
onOpenChange={setOpen}
|
||||
mode="add"
|
||||
entityName="직급"
|
||||
fields={[
|
||||
{ key: 'name', label: '직급명', type: 'text', placeholder: '직급명 입력' },
|
||||
{ key: 'status', label: '상태', type: 'select', options: [{ value: 'active', label: '활성' }, { value: 'inactive', label: '비활성' }], defaultValue: 'active' },
|
||||
]}
|
||||
onSubmit={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LineItemsTableDemo() {
|
||||
const [items, setItems] = useState([
|
||||
{ id: '1', itemName: '볼트 M10x30', quantity: 100, unitPrice: 500, supplyAmount: 50000, vat: 5000, note: '' },
|
||||
{ id: '2', itemName: '너트 M10', quantity: 200, unitPrice: 300, supplyAmount: 60000, vat: 6000, note: '' },
|
||||
]);
|
||||
return (
|
||||
<div className="max-w-3xl overflow-x-auto">
|
||||
<LineItemsTable
|
||||
items={items}
|
||||
getItemName={(i) => i.itemName}
|
||||
getQuantity={(i) => i.quantity}
|
||||
getUnitPrice={(i) => i.unitPrice}
|
||||
getSupplyAmount={(i) => i.supplyAmount}
|
||||
getVat={(i) => i.vat}
|
||||
getNote={(i) => i.note}
|
||||
onItemChange={(idx, field, value) => setItems((prev) => prev.map((item, i) => (i === idx ? { ...item, [field]: value } : item)))}
|
||||
onAddItem={() => setItems((prev) => [...prev, { id: String(prev.length + 1), itemName: '', quantity: 1, unitPrice: 0, supplyAmount: 0, vat: 0, note: '' }])}
|
||||
onRemoveItem={(idx) => setItems((prev) => prev.filter((_, i) => i !== idx))}
|
||||
totals={{ supplyAmount: items.reduce((s, i) => s + i.supplyAmount, 0), vat: items.reduce((s, i) => s + i.vat, 0), total: items.reduce((s, i) => s + i.supplyAmount + i.vat, 0) }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Preview Registry ──
|
||||
|
||||
type PreviewEntry = {
|
||||
@@ -937,6 +1028,14 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
},
|
||||
],
|
||||
|
||||
'date-range-picker.tsx': [
|
||||
{ label: 'DateRangePicker', render: () => <DateRangePickerDemo /> },
|
||||
],
|
||||
|
||||
'date-time-picker.tsx': [
|
||||
{ label: 'DateTimePicker', render: () => <DateTimePickerDemo /> },
|
||||
],
|
||||
|
||||
// ─── Atoms ───
|
||||
'BadgeSm.tsx': [
|
||||
{
|
||||
@@ -1184,6 +1283,36 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
{ label: 'Filter', render: () => <MobileFilterDemo /> },
|
||||
],
|
||||
|
||||
'ColumnSettingsPopover.tsx': [
|
||||
{ label: 'Popover', render: () => <ColumnSettingsPopoverDemo /> },
|
||||
],
|
||||
|
||||
'GenericCRUDDialog.tsx': [
|
||||
{ label: 'CRUD Dialog', render: () => <GenericCRUDDialogDemo /> },
|
||||
],
|
||||
|
||||
'ReorderButtons.tsx': [
|
||||
{
|
||||
label: 'Sizes',
|
||||
render: () => (
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">sm:</span>
|
||||
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="sm" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">xs:</span>
|
||||
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="xs" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">disabled:</span>
|
||||
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={true} isLast={true} size="sm" />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
|
||||
// ─── Organisms ───
|
||||
'EmptyState.tsx': [
|
||||
{
|
||||
@@ -1440,4 +1569,8 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
||||
'SearchableSelectionModal.tsx': [
|
||||
{ label: 'Modal', render: () => <SearchableSelectionDemo /> },
|
||||
],
|
||||
|
||||
'LineItemsTable.tsx': [
|
||||
{ label: 'Line Items', render: () => <LineItemsTableDemo /> },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -4,7 +4,6 @@ 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 {
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function AttendancePage() {
|
||||
|
||||
setSiteLocation(finalLocation);
|
||||
} else {
|
||||
// no fallback location needed
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AttendancePage] loadSettings error:', error);
|
||||
|
||||
@@ -11,23 +11,17 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useState, useEffect, useMemo, Suspense } from 'react';
|
||||
import { FileText, ArrowLeft, Calendar, User, Clock, MapPin, FileCheck } from 'lucide-react';
|
||||
import { useState, useMemo, Suspense } from 'react';
|
||||
import { FileText, ArrowLeft, Calendar, 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';
|
||||
|
||||
// 문서 유형 라벨
|
||||
|
||||
@@ -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 연동
|
||||
};
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ function EmployeeManagementContent() {
|
||||
toast.error(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
307
src/app/[locale]/(protected)/quality/qms/actions.ts
Normal file
307
src/app/[locale]/(protected)/quality/qms/actions.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
'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: '파일 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Settings, X, Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, X, Eye, EyeOff, ListChecks } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChecklistTemplateEditor } from './ChecklistTemplateEditor';
|
||||
import type { ChecklistCategory } from '../types';
|
||||
|
||||
export interface AuditDisplaySettings {
|
||||
showProgressBar: boolean;
|
||||
@@ -13,19 +15,46 @@ 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,
|
||||
@@ -49,7 +78,7 @@ export function AuditSettingsPanel({
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center 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"
|
||||
@@ -60,103 +89,183 @@ export function AuditSettingsPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 설정 항목 */}
|
||||
<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>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-200" />
|
||||
|
||||
{/* 점검표 섹션 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">점검표 옵션</h4>
|
||||
<div className="space-y-3">
|
||||
<SettingRow
|
||||
label="완료된 항목 표시"
|
||||
description="완료된 점검 항목을 목록에 표시합니다"
|
||||
checked={settings.showCompletedItems}
|
||||
onChange={() => handleToggle('showCompletedItems')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="모든 카테고리 펼치기"
|
||||
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
|
||||
checked={settings.expandAllCategories}
|
||||
onChange={() => handleToggle('expandAllCategories')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-200" />
|
||||
|
||||
{/* 빠른 설정 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">빠른 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettingsChange({
|
||||
showProgressBar: true,
|
||||
showDocumentViewer: true,
|
||||
showDocumentSection: true,
|
||||
showCompletedItems: true,
|
||||
expandAllCategories: true,
|
||||
})}
|
||||
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
모두 표시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettingsChange({
|
||||
showProgressBar: false,
|
||||
showDocumentViewer: false,
|
||||
showDocumentSection: true,
|
||||
showCompletedItems: true,
|
||||
expandAllCategories: false,
|
||||
})}
|
||||
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
간소화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* 탭 */}
|
||||
<div 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="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-xs text-gray-500">
|
||||
설정은 자동으로 저장됩니다
|
||||
</p>
|
||||
{/* 탭 컨텐츠 */}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 안내 (화면 설정 탭일 때만) */}
|
||||
{activeTab === 'display' && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-xs text-gray-500">
|
||||
설정은 자동으로 저장됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 화면 설정 탭 컨텐츠 (기존 코드 분리) =====
|
||||
|
||||
interface DisplaySettingsContentProps {
|
||||
settings: AuditDisplaySettings;
|
||||
onToggle: (key: keyof AuditDisplaySettings) => void;
|
||||
onSettingsChange: (settings: AuditDisplaySettings) => void;
|
||||
}
|
||||
|
||||
function DisplaySettingsContent({ settings, onToggle, onSettingsChange }: DisplaySettingsContentProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 레이아웃 섹션 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">레이아웃</h4>
|
||||
<div className="space-y-3">
|
||||
<SettingRow
|
||||
label="진행률 표시"
|
||||
description="상단 전체 심사 진행률 바를 표시합니다"
|
||||
checked={settings.showProgressBar}
|
||||
onChange={() => onToggle('showProgressBar')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="문서 뷰어"
|
||||
description="우측 문서 미리보기 패널을 표시합니다"
|
||||
checked={settings.showDocumentViewer}
|
||||
onChange={() => onToggle('showDocumentViewer')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="기준 문서화 섹션"
|
||||
description="중앙 기준 문서 목록 패널을 표시합니다"
|
||||
checked={settings.showDocumentSection}
|
||||
onChange={() => onToggle('showDocumentSection')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-200" />
|
||||
|
||||
{/* 점검표 섹션 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">점검표 옵션</h4>
|
||||
<div className="space-y-3">
|
||||
<SettingRow
|
||||
label="완료된 항목 표시"
|
||||
description="완료된 점검 항목을 목록에 표시합니다"
|
||||
checked={settings.showCompletedItems}
|
||||
onChange={() => onToggle('showCompletedItems')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="모든 카테고리 펼치기"
|
||||
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
|
||||
checked={settings.expandAllCategories}
|
||||
onChange={() => onToggle('expandAllCategories')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-200" />
|
||||
|
||||
{/* 빠른 설정 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">빠른 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettingsChange({
|
||||
showProgressBar: true,
|
||||
showDocumentViewer: true,
|
||||
showDocumentSection: true,
|
||||
showCompletedItems: true,
|
||||
expandAllCategories: true,
|
||||
})}
|
||||
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
모두 표시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettingsChange({
|
||||
showProgressBar: false,
|
||||
showDocumentViewer: false,
|
||||
showDocumentSection: true,
|
||||
showCompletedItems: true,
|
||||
expandAllCategories: false,
|
||||
})}
|
||||
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
간소화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 공통 설정 행 =====
|
||||
|
||||
interface SettingRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
@@ -203,4 +312,4 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
<span>화면 설정</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Plus,
|
||||
Check,
|
||||
X,
|
||||
ChevronRight,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChecklistCategory, ChecklistSubItem } from '../types';
|
||||
|
||||
interface ChecklistTemplateEditorProps {
|
||||
categories: ChecklistCategory[];
|
||||
hasChanges: boolean;
|
||||
saving: boolean;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
// 카테고리
|
||||
onAddCategory: () => void;
|
||||
onUpdateCategoryTitle: (categoryId: string, title: string) => void;
|
||||
onDeleteCategory: (categoryId: string) => void;
|
||||
onMoveCategoryUp: (index: number) => void;
|
||||
onMoveCategoryDown: (index: number) => void;
|
||||
// 하위 항목
|
||||
onAddSubItem: (categoryId: string) => void;
|
||||
onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void;
|
||||
onDeleteSubItem: (categoryId: string, subItemId: string) => void;
|
||||
onMoveSubItemUp: (categoryId: string, index: number) => void;
|
||||
onMoveSubItemDown: (categoryId: string, index: number) => void;
|
||||
// 저장/초기화
|
||||
onSave: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ChecklistTemplateEditor({
|
||||
categories,
|
||||
hasChanges,
|
||||
saving,
|
||||
loading,
|
||||
error,
|
||||
onAddCategory,
|
||||
onUpdateCategoryTitle,
|
||||
onDeleteCategory,
|
||||
onMoveCategoryUp,
|
||||
onMoveCategoryDown,
|
||||
onAddSubItem,
|
||||
onUpdateSubItemName,
|
||||
onDeleteSubItem,
|
||||
onMoveSubItemUp,
|
||||
onMoveSubItemDown,
|
||||
onSave,
|
||||
onReset,
|
||||
}: ChecklistTemplateEditorProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(categories.map(c => c.id))
|
||||
);
|
||||
|
||||
const toggleExpand = (categoryId: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(categoryId)) next.delete(categoryId);
|
||||
else next.add(categoryId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
점검표 로딩 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32 text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 카테고리 목록 */}
|
||||
<div className="space-y-1.5">
|
||||
{categories.map((category, catIdx) => (
|
||||
<CategoryEditor
|
||||
key={category.id}
|
||||
category={category}
|
||||
index={catIdx}
|
||||
isFirst={catIdx === 0}
|
||||
isLast={catIdx === categories.length - 1}
|
||||
isExpanded={expandedCategories.has(category.id)}
|
||||
onToggleExpand={() => toggleExpand(category.id)}
|
||||
onUpdateTitle={(title) => onUpdateCategoryTitle(category.id, title)}
|
||||
onDelete={() => onDeleteCategory(category.id)}
|
||||
onMoveUp={() => onMoveCategoryUp(catIdx)}
|
||||
onMoveDown={() => onMoveCategoryDown(catIdx)}
|
||||
onAddSubItem={() => onAddSubItem(category.id)}
|
||||
onUpdateSubItemName={(subItemId, name) => onUpdateSubItemName(category.id, subItemId, name)}
|
||||
onDeleteSubItem={(subItemId) => onDeleteSubItem(category.id, subItemId)}
|
||||
onMoveSubItemUp={(idx) => onMoveSubItemUp(category.id, idx)}
|
||||
onMoveSubItemDown={(idx) => onMoveSubItemDown(category.id, idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 카테고리 추가 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddCategory}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs text-blue-600 bg-blue-50 rounded-lg border border-dashed border-blue-200 hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
카테고리 추가
|
||||
</button>
|
||||
|
||||
{/* 저장/초기화 */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
disabled={!hasChanges}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
초기화
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave()}
|
||||
disabled={!hasChanges || saving}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<p className="text-[10px] text-amber-600 text-center">
|
||||
저장하지 않은 변경사항이 있습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 카테고리 편집 =====
|
||||
|
||||
interface CategoryEditorProps {
|
||||
category: ChecklistCategory;
|
||||
index: number;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onUpdateTitle: (title: string) => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onAddSubItem: () => void;
|
||||
onUpdateSubItemName: (subItemId: string, name: string) => void;
|
||||
onDeleteSubItem: (subItemId: string) => void;
|
||||
onMoveSubItemUp: (index: number) => void;
|
||||
onMoveSubItemDown: (index: number) => void;
|
||||
}
|
||||
|
||||
function CategoryEditor({
|
||||
category,
|
||||
index,
|
||||
isFirst,
|
||||
isLast,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onUpdateTitle,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onAddSubItem,
|
||||
onUpdateSubItemName,
|
||||
onDeleteSubItem,
|
||||
onMoveSubItemUp,
|
||||
onMoveSubItemDown,
|
||||
}: CategoryEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(category.title);
|
||||
|
||||
const handleSaveTitle = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed) {
|
||||
onUpdateTitle(trimmed);
|
||||
} else {
|
||||
setEditValue(category.title);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSaveTitle();
|
||||
if (e.key === 'Escape') {
|
||||
setEditValue(category.title);
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
|
||||
{/* 카테고리 헤더 */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5">
|
||||
{/* 순서 변경 */}
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 펼치기/접기 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="p-0.5 text-gray-500"
|
||||
>
|
||||
<ChevronRight className={cn(
|
||||
'h-3.5 w-3.5 transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)} />
|
||||
</button>
|
||||
|
||||
{/* 제목 */}
|
||||
{editing ? (
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleSaveTitle}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 text-xs px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" onClick={handleSaveTitle} className="p-0.5 text-green-600">
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
<button type="button" onClick={() => { setEditValue(category.title); setEditing(false); }} className="p-0.5 text-gray-400">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="flex-1 text-xs font-medium text-gray-800 truncate">
|
||||
{index + 1}. {category.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 항목 수 */}
|
||||
<span className="text-[10px] text-gray-400 mr-1">
|
||||
{category.subItems.length}
|
||||
</span>
|
||||
|
||||
{/* 편집/삭제 */}
|
||||
{!editing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditValue(category.title); setEditing(true); }}
|
||||
className="p-1 text-gray-400 hover:text-blue-600 rounded"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="p-1 text-gray-400 hover:text-red-600 rounded"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하위 항목 */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 bg-white">
|
||||
{category.subItems.map((subItem, subIdx) => (
|
||||
<SubItemEditor
|
||||
key={subItem.id}
|
||||
subItem={subItem}
|
||||
index={subIdx}
|
||||
isFirst={subIdx === 0}
|
||||
isLast={subIdx === category.subItems.length - 1}
|
||||
onUpdateName={(name) => onUpdateSubItemName(subItem.id, name)}
|
||||
onDelete={() => onDeleteSubItem(subItem.id)}
|
||||
onMoveUp={() => onMoveSubItemUp(subIdx)}
|
||||
onMoveDown={() => onMoveSubItemDown(subIdx)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 항목 추가 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddSubItem}
|
||||
className="w-full flex items-center justify-center gap-1 py-1.5 text-[10px] text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
항목 추가
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 하위 항목 편집 =====
|
||||
|
||||
interface SubItemEditorProps {
|
||||
subItem: ChecklistSubItem;
|
||||
index: number;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onUpdateName: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
}
|
||||
|
||||
function SubItemEditor({
|
||||
subItem,
|
||||
index,
|
||||
isFirst,
|
||||
isLast,
|
||||
onUpdateName,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: SubItemEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(subItem.name);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed) {
|
||||
onUpdateName(trimmed);
|
||||
} else {
|
||||
setEditValue(subItem.name);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') {
|
||||
setEditValue(subItem.name);
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
|
||||
{/* 순서 변경 */}
|
||||
<div className="flex flex-col ml-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronUp className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronDown className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 이름 */}
|
||||
{editing ? (
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 text-[11px] px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" onClick={handleSave} className="p-0.5 text-green-600">
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="flex-1 text-[11px] text-gray-700 cursor-pointer hover:text-blue-600 truncate"
|
||||
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
|
||||
>
|
||||
{subItem.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 편집/삭제 */}
|
||||
{!editing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
|
||||
className="p-0.5 text-gray-300 hover:text-blue-500"
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="p-0.5 text-gray-300 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface Day1ChecklistPanelProps {
|
||||
searchTerm: string;
|
||||
onSubItemSelect: (categoryId: string, subItemId: string) => void;
|
||||
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export function Day1ChecklistPanel({
|
||||
@@ -19,6 +20,7 @@ export function Day1ChecklistPanel({
|
||||
searchTerm,
|
||||
onSubItemSelect,
|
||||
onSubItemToggle,
|
||||
isMock,
|
||||
}: Day1ChecklistPanelProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
|
||||
@@ -48,6 +50,13 @@ 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()) {
|
||||
@@ -95,7 +104,14 @@ export function Day1ChecklistPanel({
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 검색 결과 카운트 */}
|
||||
{searchTerm && (
|
||||
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
|
||||
@@ -114,7 +130,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;
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { FileText, Download, Eye, CheckCircle2 } from 'lucide-react';
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { FileText, CheckCircle2, Upload, X, Loader2, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Day1CheckItem, StandardDocument } from '../types';
|
||||
import { toast } from 'sonner';
|
||||
import type { Day1CheckItem, TemplateDocument } from '../types';
|
||||
|
||||
const ACCEPTED_EXTENSIONS = '.pdf,.xlsx,.xls,.doc,.docx,.hwp';
|
||||
const ACCEPTED_MIME = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/haansofthwp',
|
||||
];
|
||||
const MAX_FILE_SIZE_MB = 20;
|
||||
|
||||
interface Day1DocumentSectionProps {
|
||||
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 (
|
||||
@@ -36,7 +56,14 @@ export function Day1DocumentSection({
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">기준 문서화</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">기준 문서화</h3>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
@@ -47,19 +74,23 @@ 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.5 sm:space-y-2">
|
||||
{checkItem.standardDocuments.map((doc) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
isSelected={selectedDocumentId === doc.id}
|
||||
onSelect={() => onDocumentSelect(doc.id)}
|
||||
<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>
|
||||
<DocumentUploadArea
|
||||
onUpload={onFileUpload ? (file) => onFileUpload(checkItem.subItemId, file) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 확인 버튼 */}
|
||||
@@ -91,67 +122,200 @@ export function Day1DocumentSection({
|
||||
);
|
||||
}
|
||||
|
||||
interface DocumentRowProps {
|
||||
document: StandardDocument;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
// ===== 파일 업로드 영역 =====
|
||||
|
||||
interface DocumentUploadAreaProps {
|
||||
onUpload?: (file: File) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) {
|
||||
function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
const sizeMB = file.size / (1024 * 1024);
|
||||
if (sizeMB > MAX_FILE_SIZE_MB) {
|
||||
return `파일 크기는 ${MAX_FILE_SIZE_MB}MB 이하여야 합니다.`;
|
||||
}
|
||||
// 확장자 체크
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
const allowed = ['pdf', 'xlsx', 'xls', 'doc', 'docx', 'hwp'];
|
||||
if (!ext || !allowed.includes(ext)) {
|
||||
return 'PDF, Excel, Word, HWP 파일만 업로드 가능합니다.';
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const handleFile = useCallback((file: File) => {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
setPendingFile(file);
|
||||
}, [validateFile]);
|
||||
|
||||
const handleConfirmUpload = useCallback(async () => {
|
||||
if (!pendingFile || !onUpload) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const success = await onUpload(pendingFile);
|
||||
if (success) {
|
||||
toast.success(`${pendingFile.name} 업로드 완료`);
|
||||
setPendingFile(null);
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [pendingFile, onUpload]);
|
||||
|
||||
const handleCancelUpload = useCallback(() => {
|
||||
setPendingFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
// 선택된 파일 미리보기
|
||||
if (pendingFile) {
|
||||
const ext = pendingFile.name.split('.').pop()?.toLowerCase();
|
||||
const sizeMB = (pendingFile.size / (1024 * 1024)).toFixed(1);
|
||||
return (
|
||||
<div className="mt-2 p-2.5 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center',
|
||||
ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||
)}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{pendingFile.name}</p>
|
||||
<p className="text-[10px] text-gray-500">{sizeMB} MB</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelUpload}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelUpload}
|
||||
className="flex-1 py-1.5 text-xs text-gray-600 bg-white border border-gray-200 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmUpload}
|
||||
disabled={uploading}
|
||||
className="flex-1 py-1.5 text-xs text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
업로드 중...
|
||||
</>
|
||||
) : (
|
||||
'업로드'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
onDragEnter={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }}
|
||||
onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'mt-2 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border-2 border-dashed cursor-pointer transition-colors',
|
||||
isDragging
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 text-gray-400" />
|
||||
<span className="text-xs text-gray-500">
|
||||
파일 업로드 (PDF, Excel, Word, HWP)
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 업로드된 파일 행 =====
|
||||
|
||||
interface UploadedFileRowProps {
|
||||
file: TemplateDocument;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function UploadedFileRow({ file, isSelected, onSelect, onDelete }: UploadedFileRowProps) {
|
||||
const ext = file.displayName.split('.').pop()?.toLowerCase();
|
||||
const sizeMB = (file.fileSize / (1024 * 1024)).toFixed(1);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border cursor-pointer transition-colors',
|
||||
'flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? '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-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'
|
||||
'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'
|
||||
)}>
|
||||
<FileText className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
|
||||
{/* 문서 정보 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<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>
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{file.displayName}</p>
|
||||
<p className="text-[10px] text-gray-400">{sizeMB} MB</p>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex items-center gap-1">
|
||||
{onDelete && (
|
||||
<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();
|
||||
onSelect();
|
||||
}}
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -3,13 +3,120 @@
|
||||
import React from 'react';
|
||||
import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StandardDocument } from '../types';
|
||||
import type { StandardDocument, TemplateDocument } from '../types';
|
||||
|
||||
interface Day1DocumentViewerProps {
|
||||
document: StandardDocument | null;
|
||||
uploadedFile?: TemplateDocument | null;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
|
||||
export function Day1DocumentViewer({ document, uploadedFile, isMock }: Day1DocumentViewerProps) {
|
||||
// 업로드된 파일이 선택된 경우
|
||||
if (uploadedFile) {
|
||||
const isPdf = uploadedFile.mimeType === 'application/pdf';
|
||||
const viewUrl = `/api/proxy/files/${uploadedFile.id}/view`;
|
||||
const downloadUrl = `/api/proxy/files/${uploadedFile.id}/download`;
|
||||
|
||||
// Google Docs Viewer용 공개 URL 생성 (개발/운영 서버에서만 동작)
|
||||
const isLocalhost = typeof window !== 'undefined' && (
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname.endsWith('.sam.kr')
|
||||
);
|
||||
const publicFileUrl = typeof window !== 'undefined'
|
||||
? `${window.location.origin}${viewUrl}`
|
||||
: '';
|
||||
const googleViewerUrl = `https://docs.google.com/gview?url=${encodeURIComponent(publicFileUrl)}&embedded=true`;
|
||||
|
||||
const isOfficeDoc = [
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
].includes(uploadedFile.mimeType);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-6 h-6 sm:w-8 sm:h-8 rounded flex items-center justify-center ${
|
||||
isPdf ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||
}`}>
|
||||
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm truncate max-w-[200px]">
|
||||
{uploadedFile.displayName}
|
||||
</h3>
|
||||
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||
{(uploadedFile.fileSize / (1024 * 1024)).toFixed(1)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href={viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="새 탭에서 보기"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</a>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 미리보기 */}
|
||||
<div className="flex-1 bg-gray-200 overflow-auto">
|
||||
{isPdf ? (
|
||||
<iframe
|
||||
src={viewUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={uploadedFile.displayName}
|
||||
/>
|
||||
) : isOfficeDoc && !isLocalhost ? (
|
||||
<iframe
|
||||
src={googleViewerUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={uploadedFile.displayName}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{isOfficeDoc && isLocalhost
|
||||
? '로컬 환경에서는 Office 문서 미리보기가 지원되지 않습니다'
|
||||
: '미리보기를 지원하지 않는 파일 형식입니다'}
|
||||
</p>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
className="inline-block mt-2 text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
다운로드하여 확인
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-1.5 sm:py-2 border-t border-gray-200">
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 truncate">
|
||||
{uploadedFile.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
|
||||
@@ -38,7 +145,14 @@ export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
|
||||
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
|
||||
{document.date}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface DocumentListProps {
|
||||
documents: Document[];
|
||||
routeCode: string | null;
|
||||
onViewDocument: (doc: Document, item?: DocumentItem) => void;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
@@ -27,7 +28,7 @@ const getIcon = (type: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentListProps) => {
|
||||
export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: DocumentListProps) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// 문서 카테고리 클릭 핸들러
|
||||
@@ -52,17 +53,24 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
|
||||
관련 서류{' '}
|
||||
{routeCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
|
||||
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
|
||||
관련 서류{' '}
|
||||
{routeCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
|
||||
)}
|
||||
</h2>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
||||
{!routeCode ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||
수주루트를 선택해주세요.
|
||||
수주로트를 선택해주세요.
|
||||
</div>
|
||||
) : (
|
||||
documents.map((doc) => {
|
||||
@@ -114,7 +122,7 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
|
||||
{item.code && (
|
||||
<>
|
||||
<span className="mx-1">|</span>
|
||||
로트: {item.code}
|
||||
{item.code}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -24,10 +25,12 @@ import {
|
||||
QualityDocumentUploader,
|
||||
} from './documents';
|
||||
|
||||
// 제품검사 성적서 (신규 양식) import
|
||||
// 제품검사 성적서 (FQC 양식) import
|
||||
import { FqcDocumentContent } from '@/components/quality/InspectionManagement/documents/FqcDocumentContent';
|
||||
import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument';
|
||||
import { mockReportInspectionItems } from '@/components/quality/InspectionManagement/mockData';
|
||||
import type { InspectionReportDocument as InspectionReportDocumentType } from '@/components/quality/InspectionManagement/types';
|
||||
import type { 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 type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument';
|
||||
|
||||
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
|
||||
@@ -44,6 +47,9 @@ 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로 변환
|
||||
*
|
||||
@@ -136,7 +142,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">
|
||||
@@ -160,26 +166,32 @@ 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 },
|
||||
];
|
||||
|
||||
// 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: '합격',
|
||||
};
|
||||
// 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 WorkOrder 생성
|
||||
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({
|
||||
@@ -270,10 +282,24 @@ 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 조회
|
||||
@@ -285,9 +311,53 @@ 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;
|
||||
@@ -330,11 +400,78 @@ 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 연동
|
||||
@@ -350,49 +487,73 @@ 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;
|
||||
const mockOrder = createQmsMockWorkOrder(subType);
|
||||
// 실제 WorkOrder 데이터 사용, 없으면 fallback mock
|
||||
const orderData = workOrderData || createQmsMockWorkOrder(subType);
|
||||
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
return <ScreenWorkLogContent data={orderData} />;
|
||||
case 'slat':
|
||||
return <SlatWorkLogContent data={mockOrder} />;
|
||||
return <SlatWorkLogContent data={orderData} />;
|
||||
case 'bending':
|
||||
return <BendingWorkLogContent data={mockOrder} />;
|
||||
return <BendingWorkLogContent data={orderData} />;
|
||||
default:
|
||||
// subType 미지정 시 스크린 기본
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
return <ScreenWorkLogContent data={orderData} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
|
||||
const renderReportDocument = () => {
|
||||
const loadingEl = renderWorkOrderLoading();
|
||||
if (loadingEl) return loadingEl;
|
||||
|
||||
const subType = documentItem?.subType;
|
||||
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
|
||||
// 실제 WorkOrder 데이터 사용, 없으면 fallback mock
|
||||
const orderData = workOrderData || createQmsMockWorkOrder(subType || 'screen');
|
||||
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
return <ScreenInspectionContent data={orderData} readOnly />;
|
||||
case 'bending':
|
||||
return <BendingInspectionContent data={mockOrder} readOnly />;
|
||||
return <BendingInspectionContent data={orderData} readOnly />;
|
||||
case 'slat':
|
||||
return <SlatInspectionContent data={mockOrder} readOnly />;
|
||||
return <SlatInspectionContent data={orderData} readOnly />;
|
||||
case 'jointbar':
|
||||
return <JointbarInspectionDocument />;
|
||||
default:
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
return <ScreenInspectionContent data={orderData} readOnly />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -418,6 +579,36 @@ 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) {
|
||||
@@ -453,7 +644,7 @@ export const InspectionModal = ({
|
||||
case 'import':
|
||||
return renderImportInspectionDocument();
|
||||
case 'product':
|
||||
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
|
||||
return renderProductDocument();
|
||||
case 'report':
|
||||
return renderReportDocument();
|
||||
case 'quality':
|
||||
|
||||
@@ -8,13 +8,21 @@ interface ReportListProps {
|
||||
reports: InspectionReport[];
|
||||
selectedId: string | null;
|
||||
onSelect: (report: InspectionReport) => void;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) => {
|
||||
export const ReportList = ({ reports, selectedId, onSelect, isMock }: 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">
|
||||
<h2 className="font-bold text-sm sm:text-lg text-gray-800">품질관리서 목록</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-bold text-sm sm:text-lg text-gray-800">품질관리서 목록</h2>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="bg-blue-100 text-blue-800 text-[10px] sm:text-xs font-bold px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full">
|
||||
{reports.length}건
|
||||
</span>
|
||||
@@ -32,19 +40,20 @@ export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) =
|
||||
<div
|
||||
key={report.id}
|
||||
onClick={() => onSelect(report)}
|
||||
className={`rounded-lg p-3 sm:p-4 cursor-pointer relative hover:shadow-md transition-all ${
|
||||
className={`rounded-lg p-3 sm:p-4 cursor-pointer hover:shadow-md transition-all ${
|
||||
isSelected
|
||||
? 'border-2 border-blue-500 bg-blue-50'
|
||||
: 'border border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute top-3 sm:top-4 right-3 sm:right-4 text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded">
|
||||
{report.quarter}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
@@ -52,7 +61,7 @@ export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) =
|
||||
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>
|
||||
|
||||
@@ -11,9 +11,10 @@ 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 }: RouteListProps) => {
|
||||
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode, isMock }: RouteListProps) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const handleClick = (route: RouteItem) => {
|
||||
@@ -28,17 +29,24 @@ 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">
|
||||
<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 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>
|
||||
</div>
|
||||
|
||||
<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) => {
|
||||
@@ -72,8 +80,9 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-1">수주일: {route.date}</p>
|
||||
<p className="text-xs text-gray-500 mb-2">현장: {route.site}</p>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, FileText, Download, Trash2, Eye, RefreshCw, X } from 'lucide-react';
|
||||
import { Upload, FileText, Download, Trash2, Eye, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface QualityDocumentFile {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { ChecklistCategory } from '../types';
|
||||
import { getChecklistTemplate, saveChecklistTemplate } from '../actions';
|
||||
|
||||
function generateId() {
|
||||
return `item-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function useChecklistTemplate() {
|
||||
const [templateId, setTemplateId] = useState<number | null>(null);
|
||||
const [editCategories, setEditCategories] = useState<ChecklistCategory[]>([]);
|
||||
const savedRef = useRef<ChecklistCategory[]>([]);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// === 초기 로드 ===
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
getChecklistTemplate('day1_audit')
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.success && result.data) {
|
||||
setTemplateId(result.data.id);
|
||||
const cats = result.data.categories;
|
||||
setEditCategories(structuredClone(cats));
|
||||
savedRef.current = structuredClone(cats);
|
||||
} else {
|
||||
setError(result.error || '템플릿 로드 실패');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('템플릿 로드 중 오류 발생');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// === 변경 추적 ===
|
||||
const markChanged = useCallback(() => setHasChanges(true), []);
|
||||
|
||||
// === 카테고리 CRUD ===
|
||||
const addCategory = useCallback(() => {
|
||||
setEditCategories(prev => [
|
||||
...prev,
|
||||
{ id: generateId(), title: '새 카테고리', subItems: [] },
|
||||
]);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const updateCategoryTitle = useCallback((categoryId: string, title: string) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => cat.id === categoryId ? { ...cat, title } : cat)
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const deleteCategory = useCallback((categoryId: string) => {
|
||||
setEditCategories(prev => prev.filter(cat => cat.id !== categoryId));
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
// === 카테고리 순서 변경 ===
|
||||
const moveCategoryUp = useCallback((index: number) => {
|
||||
if (index <= 0) return;
|
||||
setEditCategories(prev => {
|
||||
const next = [...prev];
|
||||
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
||||
return next;
|
||||
});
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const moveCategoryDown = useCallback((index: number) => {
|
||||
setEditCategories(prev => {
|
||||
if (index >= prev.length - 1) return prev;
|
||||
const next = [...prev];
|
||||
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
||||
return next;
|
||||
});
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
// === 하위 항목 CRUD ===
|
||||
const addSubItem = useCallback((categoryId: string) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: [
|
||||
...cat.subItems,
|
||||
{ id: generateId(), name: '새 항목', isCompleted: false },
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const updateSubItemName = useCallback((categoryId: string, subItemId: string, name: string) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.map(item =>
|
||||
item.id === subItemId ? { ...item, name } : item
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const deleteSubItem = useCallback((categoryId: string, subItemId: string) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.filter(item => item.id !== subItemId),
|
||||
};
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
// === 하위 항목 순서 변경 ===
|
||||
const moveSubItemUp = useCallback((categoryId: string, index: number) => {
|
||||
if (index <= 0) return;
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
const items = [...cat.subItems];
|
||||
[items[index - 1], items[index]] = [items[index], items[index - 1]];
|
||||
return { ...cat, subItems: items };
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const moveSubItemDown = useCallback((categoryId: string, index: number) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
if (index >= cat.subItems.length - 1) return cat;
|
||||
const items = [...cat.subItems];
|
||||
[items[index], items[index + 1]] = [items[index + 1], items[index]];
|
||||
return { ...cat, subItems: items };
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
// === 저장 ===
|
||||
const saveTemplate = useCallback(async () => {
|
||||
if (!templateId) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// API용 데이터: isCompleted 제거
|
||||
const apiCategories = editCategories.map(cat => ({
|
||||
id: cat.id,
|
||||
title: cat.title,
|
||||
subItems: cat.subItems.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
})),
|
||||
}));
|
||||
|
||||
const result = await saveChecklistTemplate(templateId, {
|
||||
categories: apiCategories,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
const cats = result.data.categories;
|
||||
setEditCategories(structuredClone(cats));
|
||||
savedRef.current = structuredClone(cats);
|
||||
setHasChanges(false);
|
||||
toast.success('점검표가 저장되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editCategories, templateId]);
|
||||
|
||||
// === 초기화 ===
|
||||
const resetToSaved = useCallback(() => {
|
||||
setEditCategories(structuredClone(savedRef.current));
|
||||
setHasChanges(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 데이터
|
||||
templateId,
|
||||
editCategories,
|
||||
hasChanges,
|
||||
saving,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// 카테고리
|
||||
addCategory,
|
||||
updateCategoryTitle,
|
||||
deleteCategory,
|
||||
moveCategoryUp,
|
||||
moveCategoryDown,
|
||||
|
||||
// 하위 항목
|
||||
addSubItem,
|
||||
updateSubItemName,
|
||||
deleteSubItem,
|
||||
moveSubItemUp,
|
||||
moveSubItemDown,
|
||||
|
||||
// 저장/초기화
|
||||
saveTemplate,
|
||||
resetToSaved,
|
||||
};
|
||||
}
|
||||
147
src/app/[locale]/(protected)/quality/qms/hooks/useDay1Audit.ts
Normal file
147
src/app/[locale]/(protected)/quality/qms/hooks/useDay1Audit.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { ChecklistCategory } from '../types';
|
||||
import { getChecklistTemplate, toggleTemplateItem } from '../actions';
|
||||
|
||||
export function useDay1Audit() {
|
||||
// 데이터 상태
|
||||
const [templateId, setTemplateId] = useState<number | null>(null);
|
||||
const [categories, setCategories] = useState<ChecklistCategory[]>([]);
|
||||
|
||||
// 선택 상태
|
||||
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
|
||||
// 로딩 상태
|
||||
const [loadingChecklist, setLoadingChecklist] = useState(true);
|
||||
const [pendingToggleIds, setPendingToggleIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 마운트 시 점검표 로드 (checklist_templates API)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingChecklist(true);
|
||||
|
||||
getChecklistTemplate('day1_audit')
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.success && result.data) {
|
||||
setTemplateId(result.data.id);
|
||||
setCategories(result.data.categories);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingChecklist(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// 진행률 계산
|
||||
const day1Progress = useMemo(() => {
|
||||
const total = categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
|
||||
const completed = categories.reduce(
|
||||
(sum, cat) => sum + cat.subItems.filter((item) => item.isCompleted).length,
|
||||
0,
|
||||
);
|
||||
return { completed, total };
|
||||
}, [categories]);
|
||||
|
||||
// 선택된 항목의 완료 여부
|
||||
const isSelectedItemCompleted = useMemo(() => {
|
||||
if (!selectedSubItemId) return false;
|
||||
for (const cat of categories) {
|
||||
const item = cat.subItems.find((sub) => sub.id === selectedSubItemId);
|
||||
if (item) return item.isCompleted;
|
||||
}
|
||||
return false;
|
||||
}, [categories, selectedSubItemId]);
|
||||
|
||||
// 선택된 점검 항목 정보 (중앙 패널용)
|
||||
const selectedCheckItem = useMemo(() => {
|
||||
if (!selectedSubItemId || !selectedCategoryId) return null;
|
||||
const category = categories.find((c) => c.id === selectedCategoryId);
|
||||
if (!category) return null;
|
||||
const subItem = category.subItems.find((s) => s.id === selectedSubItemId);
|
||||
if (!subItem) return null;
|
||||
|
||||
return {
|
||||
id: `check-${subItem.id}`,
|
||||
categoryId: category.id,
|
||||
subItemId: subItem.id,
|
||||
title: subItem.name,
|
||||
description: '',
|
||||
buttonLabel: '기준/매뉴얼 확인',
|
||||
standardDocuments: [],
|
||||
};
|
||||
}, [selectedSubItemId, selectedCategoryId, categories]);
|
||||
|
||||
// === 핸들러 ===
|
||||
|
||||
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
|
||||
setSelectedCategoryId(categoryId);
|
||||
setSelectedSubItemId(subItemId);
|
||||
}, []);
|
||||
|
||||
const handleSubItemToggle = useCallback(async (categoryId: string, subItemId: string) => {
|
||||
if (!templateId || pendingToggleIds.has(subItemId)) return;
|
||||
setPendingToggleIds((prev) => new Set(prev).add(subItemId));
|
||||
|
||||
try {
|
||||
const result = await toggleTemplateItem(templateId, subItemId);
|
||||
if (result.success && result.data) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.map((item) => {
|
||||
if (item.id !== subItemId) return item;
|
||||
return { ...item, isCompleted: result.data!.isCompleted };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(result.error || '항목 상태 변경에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setPendingToggleIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(subItemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [templateId, pendingToggleIds]);
|
||||
|
||||
const handleConfirmComplete = useCallback(() => {
|
||||
if (selectedCategoryId && selectedSubItemId) {
|
||||
handleSubItemToggle(selectedCategoryId, selectedSubItemId);
|
||||
}
|
||||
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
|
||||
|
||||
return {
|
||||
// 데이터
|
||||
templateId,
|
||||
categories,
|
||||
day1Progress,
|
||||
selectedCheckItem,
|
||||
isSelectedItemCompleted,
|
||||
|
||||
// 선택
|
||||
selectedSubItemId,
|
||||
handleSubItemSelect,
|
||||
|
||||
// 토글
|
||||
handleSubItemToggle,
|
||||
handleConfirmComplete,
|
||||
pendingToggleIds,
|
||||
|
||||
// 로딩
|
||||
loadingChecklist,
|
||||
|
||||
// Mock 여부
|
||||
isMock: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
'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,
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/ty
|
||||
export const MOCK_WORK_ORDER: WorkOrder = {
|
||||
id: 'wo-1',
|
||||
orderNo: 'KD-WO-240924-01',
|
||||
productCode: 'WY-SC780',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
processCode: 'screen',
|
||||
processName: 'screen',
|
||||
@@ -97,13 +98,14 @@ 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: [
|
||||
@@ -120,6 +122,7 @@ 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: [
|
||||
@@ -133,6 +136,7 @@ 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: [
|
||||
@@ -146,6 +150,7 @@ 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: [
|
||||
@@ -156,6 +161,7 @@ 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: [],
|
||||
@@ -164,6 +170,7 @@ 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: [],
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Header } from './components/Header';
|
||||
import { 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 { 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';
|
||||
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';
|
||||
|
||||
// 기본 설정값
|
||||
const DEFAULT_SETTINGS: AuditDisplaySettings = {
|
||||
@@ -41,195 +36,112 @@ export default function QualityInspectionPage() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [displaySettings, setDisplaySettings] = useState<AuditDisplaySettings>(DEFAULT_SETTINGS);
|
||||
|
||||
// 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);
|
||||
// 1일차 커스텀 훅
|
||||
const {
|
||||
templateId,
|
||||
categories,
|
||||
day1Progress,
|
||||
selectedCheckItem,
|
||||
isSelectedItemCompleted,
|
||||
selectedSubItemId,
|
||||
handleSubItemSelect,
|
||||
handleSubItemToggle,
|
||||
handleConfirmComplete,
|
||||
isMock: day1IsMock,
|
||||
} = useDay1Audit();
|
||||
|
||||
// 2일차(로트추적) 필터 상태
|
||||
const [selectedYear, setSelectedYear] = useState(2025);
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
// 점검표 템플릿 관리 훅 (설정 모달용)
|
||||
const checklistTemplate = useChecklistTemplate();
|
||||
|
||||
// 2일차 선택 상태
|
||||
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
|
||||
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
|
||||
// 업로드된 파일 상태 (subItemId별)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, TemplateDocument[]>>({});
|
||||
const [selectedUploadedFile, setSelectedUploadedFile] = useState<TemplateDocument | null>(null);
|
||||
|
||||
// 2일차 루트 데이터 상태 (완료 토글용)
|
||||
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(MOCK_ROUTES_INITIAL);
|
||||
// 선택된 항목 변경 시 파일 목록 로드 + 업로드 파일 선택 초기화
|
||||
useEffect(() => {
|
||||
setSelectedUploadedFile(null);
|
||||
if (!selectedSubItemId || !templateId) return;
|
||||
if (uploadedFiles[selectedSubItemId]) return; // 이미 로드됨
|
||||
|
||||
// 모달 상태
|
||||
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++;
|
||||
});
|
||||
});
|
||||
getTemplateDocuments(templateId, selectedSubItemId).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setUploadedFiles((prev) => ({ ...prev, [selectedSubItemId]: result.data! }));
|
||||
}
|
||||
});
|
||||
return { completed, total };
|
||||
}, [routesData]);
|
||||
}, [selectedSubItemId, templateId]);
|
||||
|
||||
// ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) =====
|
||||
// 파일 업로드 핸들러
|
||||
const handleFileUpload = useCallback(async (subItemId: string, file: File): Promise<boolean> => {
|
||||
if (!templateId) {
|
||||
toast.error('점검표 템플릿이 로드되지 않았습니다.');
|
||||
return false;
|
||||
}
|
||||
const result = await uploadTemplateDocument(templateId, subItemId, file);
|
||||
if (!result.success) {
|
||||
toast.error(result.error || '파일 업로드에 실패했습니다.');
|
||||
return false;
|
||||
}
|
||||
// 업로드 성공 시 파일 목록에 추가
|
||||
if (result.data) {
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[subItemId]: [result.data!, ...(prev[subItemId] || [])],
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}, [templateId]);
|
||||
|
||||
// 파일 삭제 핸들러
|
||||
const handleFileDelete = useCallback(async (fileId: number, subItemId: string) => {
|
||||
const result = await deleteTemplateDocument(fileId);
|
||||
if (result.success) {
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[subItemId]: (prev[subItemId] || []).filter((f) => f.id !== fileId),
|
||||
}));
|
||||
toast.success('파일이 삭제되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '파일 삭제에 실패했습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 2일차 커스텀 훅
|
||||
const {
|
||||
selectedYear,
|
||||
selectedQuarter,
|
||||
searchTerm,
|
||||
handleYearChange,
|
||||
handleQuarterChange,
|
||||
handleSearchChange,
|
||||
filteredReports,
|
||||
currentRoutes,
|
||||
currentDocuments,
|
||||
day2Progress,
|
||||
selectedReport,
|
||||
selectedRoute,
|
||||
handleReportSelect,
|
||||
handleRouteSelect,
|
||||
modalOpen,
|
||||
selectedDoc,
|
||||
selectedDocItem,
|
||||
handleViewDocument,
|
||||
setModalOpen,
|
||||
handleToggleItem,
|
||||
isMock: day2IsMock,
|
||||
} = useDay2LotAudit();
|
||||
|
||||
// 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션)
|
||||
const filteredDay1Categories = useMemo(() => {
|
||||
if (displaySettings.showCompletedItems) return day1Categories;
|
||||
if (displaySettings.showCompletedItems) return categories;
|
||||
|
||||
return day1Categories.map(category => ({
|
||||
return categories.map(category => ({
|
||||
...category,
|
||||
subItems: category.subItems.filter(item => !item.isCompleted),
|
||||
})).filter(category => category.subItems.length > 0);
|
||||
}, [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;
|
||||
});
|
||||
}, []);
|
||||
}, [categories, displaySettings.showCompletedItems]);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-hidden">
|
||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-auto">
|
||||
{/* 헤더 (설정 버튼 포함) */}
|
||||
<Header
|
||||
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
|
||||
@@ -283,9 +195,9 @@ export default function QualityInspectionPage() {
|
||||
|
||||
{activeDay === 1 ? (
|
||||
// ===== 기준/매뉴얼 심사 심사 =====
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-[500px]">
|
||||
{/* 좌측: 점검표 항목 */}
|
||||
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${
|
||||
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
|
||||
? 'lg:col-span-3'
|
||||
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
|
||||
@@ -298,59 +210,69 @@ 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-0 lg:h-full overflow-auto ${
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
|
||||
}`}>
|
||||
<Day1DocumentSection
|
||||
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-0 lg:h-full overflow-auto ${
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
|
||||
}`}>
|
||||
<Day1DocumentViewer document={selectedStandardDoc} />
|
||||
<Day1DocumentViewer document={null} uploadedFile={selectedUploadedFile} isMock={day1IsMock} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// ===== 로트 추적 심사 심사 =====
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-[500px]">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||
<ReportList
|
||||
reports={filteredReports}
|
||||
selectedId={selectedReport?.id || null}
|
||||
onSelect={handleReportSelect}
|
||||
isMock={day2IsMock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||
<RouteList
|
||||
routes={currentRoutes}
|
||||
selectedId={selectedRoute?.id || null}
|
||||
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-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||
<DocumentList
|
||||
documents={currentDocuments}
|
||||
routeCode={selectedRoute?.code || null}
|
||||
onViewDocument={handleViewDocument}
|
||||
isMock={day2IsMock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -362,6 +284,25 @@ 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
|
||||
|
||||
@@ -14,6 +14,7 @@ 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[];
|
||||
@@ -42,6 +43,8 @@ export interface DocumentItem {
|
||||
code?: string;
|
||||
// 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용)
|
||||
subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
|
||||
// 작업일지/중간검사에서 실제 WorkOrder 데이터 로딩용
|
||||
workOrderId?: number;
|
||||
}
|
||||
|
||||
// ===== 기준/매뉴얼 심사 심사 타입 =====
|
||||
@@ -92,3 +95,28 @@ 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;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
Plus,
|
||||
Users,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
@@ -58,13 +57,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;
|
||||
@@ -176,7 +175,7 @@ export default function CustomerAccountManagementPage() {
|
||||
const paginatedClients = filteredClients;
|
||||
|
||||
// 모바일용 인피니티 스크롤 데이터
|
||||
const mobileClients = filteredClients.slice(0, mobileDisplayCount);
|
||||
const _mobileClients = filteredClients.slice(0, mobileDisplayCount);
|
||||
|
||||
// Intersection Observer를 이용한 인피니티 스크롤
|
||||
useEffect(() => {
|
||||
@@ -262,12 +261,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 },
|
||||
{ key: "code", label: "코드", className: "px-4", sortable: true, copyable: true },
|
||||
{ key: "clientType", label: "구분", className: "px-4", sortable: 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 },
|
||||
{ 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 },
|
||||
], []);
|
||||
|
||||
// 핸들러 - 페이지 기반 네비게이션
|
||||
@@ -275,7 +274,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`);
|
||||
};
|
||||
|
||||
@@ -283,7 +282,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);
|
||||
};
|
||||
@@ -304,7 +303,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);
|
||||
@@ -314,7 +313,7 @@ export default function CustomerAccountManagementPage() {
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const _toggleSelectAll = () => {
|
||||
if (
|
||||
selectedItems.size === paginatedClients.length &&
|
||||
paginatedClients.length > 0
|
||||
@@ -326,7 +325,7 @@ export default function CustomerAccountManagementPage() {
|
||||
};
|
||||
|
||||
// 일괄 삭제
|
||||
const handleBulkDelete = () => {
|
||||
const _handleBulkDelete = () => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.error("삭제할 항목을 선택해주세요");
|
||||
return;
|
||||
|
||||
@@ -27,9 +27,6 @@ import {
|
||||
PenLine,
|
||||
Factory,
|
||||
XCircle,
|
||||
FileSpreadsheet,
|
||||
FileCheck,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
RotateCcw,
|
||||
@@ -275,12 +272,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);
|
||||
@@ -432,7 +429,7 @@ export default function OrderDetailPage() {
|
||||
};
|
||||
|
||||
// 수주 삭제
|
||||
const handleDelete = () => {
|
||||
const _handleDelete = () => {
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
{ 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: "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: "status", label: "상태", className: "px-2", sortable: true },
|
||||
{ key: "remarks", label: "비고", className: "px-2" },
|
||||
{ key: "remarks", label: "비고", className: "px-2", copyable: true },
|
||||
], []);
|
||||
|
||||
// 테이블 행 렌더링 (16개 컬럼: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고)
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
Circle,
|
||||
Activity,
|
||||
Play,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
@@ -47,143 +48,16 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 상태 타입
|
||||
type WorkOrderStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 데이터 타입
|
||||
interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNumber: string; // KD-WO-XXXXXX-XX
|
||||
process: string; // 공정명
|
||||
quantity: number;
|
||||
status: WorkOrderStatus;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
// 생산지시 상세 데이터 타입
|
||||
interface ProductionOrderDetail {
|
||||
id: string;
|
||||
productionOrderNumber: string;
|
||||
orderNumber: string;
|
||||
productionOrderDate: string;
|
||||
dueDate: string;
|
||||
quantity: number;
|
||||
status: ProductionOrderStatus;
|
||||
client: string;
|
||||
siteName: string;
|
||||
productType: string;
|
||||
pendingWorkOrderCount: number; // 생성 예정 작업지시 수
|
||||
workOrders: WorkOrder[];
|
||||
}
|
||||
|
||||
// 샘플 생산지시 상세 데이터
|
||||
const SAMPLE_PRODUCTION_ORDER_DETAILS: Record<string, ProductionOrderDetail> = {
|
||||
"PO-001": {
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-15",
|
||||
quantity: 2,
|
||||
status: "completed", // 생산완료 상태 - 목록 버튼만 표시
|
||||
client: "호반건설(주)",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0, // 작업지시 이미 생성됨
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-001",
|
||||
workOrderNumber: "KD-WO-251217-07",
|
||||
process: "재단",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-002",
|
||||
workOrderNumber: "KD-WO-251217-08",
|
||||
process: "조립",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-003",
|
||||
workOrderNumber: "KD-WO-251217-09",
|
||||
process: "검수",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
"PO-002": {
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 10,
|
||||
status: "waiting",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 5, // 생성 예정 작업지시 5개 (일괄생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-003": {
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 1,
|
||||
status: "waiting",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 1, // 생성 예정 작업지시 1개 (단일 생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-004": {
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
productionOrderDate: "2025-12-20",
|
||||
dueDate: "2026-02-03",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
client: "현대건설(주)",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0,
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-004",
|
||||
workOrderNumber: "KD-WO-251220-01",
|
||||
process: "재단",
|
||||
quantity: 3,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-005",
|
||||
workOrderNumber: "KD-WO-251220-02",
|
||||
process: "조립",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions";
|
||||
import { createProductionOrder } from "@/components/orders/actions";
|
||||
import type {
|
||||
ProductionOrderDetail,
|
||||
ProductionStatus,
|
||||
ProductionWorkOrder,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
|
||||
// 공정 진행 현황 컴포넌트
|
||||
function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] }) {
|
||||
if (workOrders.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -202,7 +76,9 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = workOrders.filter((w) => w.status === "completed").length;
|
||||
const completedCount = workOrders.filter(
|
||||
(w) => w.status === "completed" || w.status === "shipped"
|
||||
).length;
|
||||
const totalCount = workOrders.length;
|
||||
const progressPercent = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
@@ -237,25 +113,27 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
wo.status === "completed"
|
||||
wo.status === "completed" || wo.status === "shipped"
|
||||
? "bg-green-500 text-white"
|
||||
: wo.status === "in_progress"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{wo.status === "completed" ? (
|
||||
{wo.status === "completed" || wo.status === "shipped" ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{wo.process}</span>
|
||||
<span className="text-xs text-muted-foreground">{wo.processName}</span>
|
||||
</div>
|
||||
{index < workOrders.length - 1 && (
|
||||
<div
|
||||
className={`w-12 h-0.5 mx-1 ${
|
||||
wo.status === "completed" ? "bg-green-500" : "bg-gray-200"
|
||||
wo.status === "completed" || wo.status === "shipped"
|
||||
? "bg-green-500"
|
||||
: "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
@@ -269,13 +147,13 @@ function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
const config: Record<ProductionOrderStatus, { label: string; className: string }> = {
|
||||
function getStatusBadge(status: ProductionStatus) {
|
||||
const config: Record<ProductionStatus, { label: string; className: string }> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
in_production: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
@@ -289,22 +167,16 @@ function getStatusBadge(status: ProductionOrderStatus) {
|
||||
}
|
||||
|
||||
// 작업지시 상태 배지 헬퍼
|
||||
function getWorkOrderStatusBadge(status: WorkOrderStatus) {
|
||||
const config: Record<WorkOrderStatus, { label: string; className: string }> = {
|
||||
pending: {
|
||||
label: "대기",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "작업중",
|
||||
className: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
},
|
||||
completed: {
|
||||
label: "완료",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
function getWorkOrderStatusBadge(status: string) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
unassigned: { label: "미배정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
pending: { label: "대기", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
waiting: { label: "준비중", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
|
||||
in_progress: { label: "작업중", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
completed: { label: "완료", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
shipped: { label: "출하", className: "bg-purple-100 text-purple-700 border-purple-200" },
|
||||
};
|
||||
const c = config[status];
|
||||
const c = config[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
@@ -318,99 +190,33 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 샘플 공정 목록 (작업지시 생성 팝업에 표시용)
|
||||
const SAMPLE_PROCESSES = [
|
||||
{ id: "P1", name: "1.1 백판필름", quantity: 10 },
|
||||
{ id: "P2", name: "2. 하안마감재", quantity: 10 },
|
||||
{ id: "P3", name: "3.1 케이스", quantity: 10 },
|
||||
{ id: "P4", name: "4. 연기단자", quantity: 10 },
|
||||
{ id: "P5", name: "5. 가이드레일 하부브라켓", quantity: 10 },
|
||||
];
|
||||
|
||||
// BOM 품목 타입
|
||||
interface BomItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
lotNo: string;
|
||||
requiredQty: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
// BOM 공정 분류 타입
|
||||
interface BomProcessGroup {
|
||||
processName: string;
|
||||
sizeSpec?: string;
|
||||
items: BomItem[];
|
||||
}
|
||||
|
||||
// BOM 품목별 공정 분류 목데이터
|
||||
const SAMPLE_BOM_PROCESS_GROUPS: BomProcessGroup[] = [
|
||||
{
|
||||
processName: "1.1 백판필름",
|
||||
sizeSpec: "[20-70]",
|
||||
items: [
|
||||
{ id: "B1", itemCode: "①", itemName: "아연판", spec: "EGI.6T", lotNo: "LOT-M-2024-001", requiredQty: 4300, qty: 8 },
|
||||
{ id: "B2", itemCode: "②", itemName: "가이드레일", spec: "EGI.6T", lotNo: "LOT-M-2024-002", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B3", itemCode: "③", itemName: "C형", spec: "EGI.6ST", lotNo: "LOT-M-2024-003", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B4", itemCode: "④", itemName: "D형", spec: "EGI.6T", lotNo: "LOT-M-2024-004", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B5", itemCode: "⑤", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-005", requiredQty: 4300, qty: 2 },
|
||||
{ id: "B6", itemCode: "⑥", itemName: "R/RBASE", spec: "EGI.5ST", lotNo: "LOT-M-2024-006", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "2. 하안마감재",
|
||||
sizeSpec: "[60-40]",
|
||||
items: [
|
||||
{ id: "B7", itemCode: "①", itemName: "하안마감재", spec: "EGI.5T", lotNo: "LOT-M-2024-007", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B8", itemCode: "②", itemName: "하안보강재판", spec: "EGI.5T", lotNo: "LOT-M-2024-009", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B9", itemCode: "③", itemName: "하안보강멈틀", spec: "EGI.1T", lotNo: "LOT-M-2024-010", requiredQty: 0, qty: 2 },
|
||||
{ id: "B10", itemCode: "④", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-008", requiredQty: 3000, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "3.1 케이스",
|
||||
sizeSpec: "[500*330]",
|
||||
items: [
|
||||
{ id: "B11", itemCode: "①", itemName: "전판", spec: "EGI.5ST", lotNo: "LOT-M-2024-011", requiredQty: 0, qty: 2 },
|
||||
{ id: "B12", itemCode: "②", itemName: "전멈틀", spec: "EGI.5T", lotNo: "LOT-M-2024-012", requiredQty: 0, qty: 4 },
|
||||
{ id: "B13", itemCode: "③⑤", itemName: "타부멈틀", spec: "", lotNo: "LOT-M-2024-013", requiredQty: 0, qty: 2 },
|
||||
{ id: "B14", itemCode: "④", itemName: "좌우판-HW", spec: "", lotNo: "LOT-M-2024-014", requiredQty: 0, qty: 2 },
|
||||
{ id: "B15", itemCode: "⑥", itemName: "상부판", spec: "", lotNo: "LOT-M-2024-015", requiredQty: 1220, qty: 5 },
|
||||
{ id: "B16", itemCode: "⑦", itemName: "측판(전산판)", spec: "", lotNo: "LOT-M-2024-016", requiredQty: 0, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "4. 연기단자",
|
||||
sizeSpec: "",
|
||||
items: [
|
||||
{ id: "B17", itemCode: "-", itemName: "레일홀 (W50)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-017", requiredQty: 0, qty: 4 },
|
||||
{ id: "B18", itemCode: "-", itemName: "카이드홀 (W60)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-018", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProductionOrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const productionOrderId = params.id as string;
|
||||
const orderId = params.id as string;
|
||||
|
||||
const [productionOrder, setProductionOrder] = useState<ProductionOrderDetail | null>(null);
|
||||
const [detail, setDetail] = useState<ProductionOrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateWorkOrderDialogOpen, setIsCreateWorkOrderDialogOpen] = useState(false);
|
||||
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
|
||||
const [createdWorkOrders, setCreatedWorkOrders] = useState<string[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [bomOpen, setBomOpen] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
const loadDetail = async () => {
|
||||
setLoading(true);
|
||||
const result = await getProductionOrderDetail(orderId);
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
} else {
|
||||
setDetail(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => {
|
||||
const found = SAMPLE_PRODUCTION_ORDER_DETAILS[productionOrderId];
|
||||
setProductionOrder(found || null);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [productionOrderId]);
|
||||
loadDetail();
|
||||
}, [orderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales/production-orders");
|
||||
@@ -423,19 +229,13 @@ export default function ProductionOrderDetailPage() {
|
||||
const handleConfirmCreateWorkOrder = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
// API 호출 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 생성된 작업지시서 목록 (실제로는 API 응답에서 받음)
|
||||
const workOrderCount = productionOrder?.pendingWorkOrderCount || 0;
|
||||
const created = Array.from({ length: workOrderCount }, (_, i) =>
|
||||
`KD-WO-251223-${String(i + 1).padStart(2, "0")}`
|
||||
);
|
||||
setCreatedWorkOrders(created);
|
||||
|
||||
// 확인 팝업 닫고 성공 팝업 열기
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
const result = await createProductionOrder(orderId);
|
||||
if (result.success) {
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
} else {
|
||||
toast.error(result.error || "작업지시 생성에 실패했습니다.");
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -457,7 +257,7 @@ export default function ProductionOrderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!productionOrder) {
|
||||
if (!detail) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="생산지시 정보를 불러올 수 없습니다"
|
||||
@@ -468,6 +268,9 @@ export default function ProductionOrderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasWorkOrders = detail.workOrders.length > 0;
|
||||
const canCreateWorkOrders = detail.productionStatus === "waiting" && !hasWorkOrders;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
@@ -476,9 +279,9 @@ export default function ProductionOrderDetailPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<span>생산지시 상세</span>
|
||||
<code className="text-sm font-mono bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
{productionOrder.productionOrderNumber}
|
||||
{detail.orderNumber}
|
||||
</code>
|
||||
{getStatusBadge(productionOrder.status)}
|
||||
{getStatusBadge(detail.productionStatus)}
|
||||
</div>
|
||||
}
|
||||
icon={Factory}
|
||||
@@ -488,10 +291,7 @@ export default function ProductionOrderDetailPage() {
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{/* 작업지시 생성 버튼 - 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
{canCreateWorkOrders && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<ClipboardList className="h-4 w-4 mr-2" />
|
||||
작업지시 생성
|
||||
@@ -503,7 +303,7 @@ export default function ProductionOrderDetailPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 공정 진행 현황 */}
|
||||
<ProcessProgress workOrders={productionOrder.workOrders} />
|
||||
<ProcessProgress workOrders={detail.workOrders} />
|
||||
|
||||
{/* 기본 정보 & 거래처/현장 정보 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -514,11 +314,10 @@ export default function ProductionOrderDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="생산지시번호" value={productionOrder.productionOrderNumber} />
|
||||
<InfoItem label="수주번호" value={productionOrder.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={productionOrder.productionOrderDate} />
|
||||
<InfoItem label="납기일" value={productionOrder.dueDate} />
|
||||
<InfoItem label="수량" value={`${productionOrder.quantity}개`} />
|
||||
<InfoItem label="수주번호" value={detail.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={detail.productionOrderedAt} />
|
||||
<InfoItem label="납기일" value={detail.deliveryDate} />
|
||||
<InfoItem label="개소" value={`${formatNumber(detail.nodeCount)}개소`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -530,112 +329,108 @@ export default function ProductionOrderDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="거래처" value={productionOrder.client} />
|
||||
<InfoItem label="현장명" value={productionOrder.siteName} />
|
||||
<InfoItem label="제품유형" value={productionOrder.productType} />
|
||||
<InfoItem label="거래처" value={detail.clientName} />
|
||||
<InfoItem label="현장명" value={detail.siteName} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* BOM 품목별 공정 분류 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">BOM 품목별 공정 분류</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 절곡 부품 전개도 정보 헤더 */}
|
||||
<p className="text-sm font-medium text-muted-foreground border-b pb-2">
|
||||
절곡 부품 전개도 정보
|
||||
</p>
|
||||
|
||||
{/* 공정별 테이블 */}
|
||||
{SAMPLE_BOM_PROCESS_GROUPS.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
{/* 공정명 헤더 */}
|
||||
<h4 className="text-sm font-semibold">
|
||||
{group.processName}
|
||||
{group.sizeSpec && (
|
||||
<span className="ml-2 text-muted-foreground font-normal">
|
||||
{group.sizeSpec}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{/* BOM 품목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="w-[60px] text-center">항목코드</TableHead>
|
||||
<TableHead>세부품명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>LOT NO</TableHead>
|
||||
<TableHead className="text-right">필요수량</TableHead>
|
||||
<TableHead className="text-center w-[60px]">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.lotNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.requiredQty > 0 ? formatNumber(item.requiredQty) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.qty}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* BOM 품목별 공정 분류 (접이식) */}
|
||||
{detail.bomProcessGroups.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setBomOpen((prev) => !prev)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
BOM 품목별 공정 분류
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({detail.bomProcessGroups.length}개 공정)
|
||||
</span>
|
||||
</CardTitle>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform ${
|
||||
bomOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</CardHeader>
|
||||
{bomOpen && (
|
||||
<CardContent className="space-y-6 pt-0">
|
||||
{detail.bomProcessGroups.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Badge variant="outline">{group.processName}</Badge>
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
{group.items.length}건
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
{/* 합계 정보 */}
|
||||
<div className="flex justify-between items-center pt-4 border-t text-sm">
|
||||
<span className="text-muted-foreground">총 부품 종류: 18개</span>
|
||||
<span className="text-muted-foreground">총 중량: 25.8 kg</span>
|
||||
<span className="text-muted-foreground">비고: VT칼 작업 완료 후 절곡 진행</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">수량</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
<TableHead>개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item, idx) => (
|
||||
<TableRow key={`${item.id}-${idx}`}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.quantity)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.unitPrice)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.totalPrice)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{item.nodeName || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 작업지시서 목록 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">작업지시서 목록</CardTitle>
|
||||
{/* 버튼 조건: 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
{canCreateWorkOrders && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
{productionOrder.pendingWorkOrderCount > 1
|
||||
? "작업지시 일괄생성"
|
||||
: "작업지시 생성"}
|
||||
작업지시 생성
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{productionOrder.workOrders.length === 0 ? (
|
||||
{!hasWorkOrders ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ClipboardList className="h-12 w-12 text-gray-300" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
아직 작업지시서가 생성되지 않았습니다.
|
||||
</p>
|
||||
{productionOrder.pendingWorkOrderCount > 0 && (
|
||||
{canCreateWorkOrders && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
위 버튼을 클릭하여 BOM 기반 작업지시서를 자동 생성하세요.
|
||||
</p>
|
||||
@@ -649,23 +444,23 @@ export default function ProductionOrderDetailPage() {
|
||||
<TableRow>
|
||||
<TableHead>작업지시번호</TableHead>
|
||||
<TableHead>공정</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>담당자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{productionOrder.workOrders.map((wo) => (
|
||||
{detail.workOrders.map((wo) => (
|
||||
<TableRow key={wo.id}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{wo.workOrderNumber}
|
||||
{wo.workOrderNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{wo.process}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개</TableCell>
|
||||
<TableCell>{wo.processName}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개소</TableCell>
|
||||
<TableCell>{getWorkOrderStatusBadge(wo.status)}</TableCell>
|
||||
<TableCell>{wo.assignee}</TableCell>
|
||||
<TableCell>{wo.assignees.length > 0 ? wo.assignees.join(", ") : "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -676,7 +471,7 @@ export default function ProductionOrderDetailPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 팝업1: 작업지시 생성 확인 다이얼로그 */}
|
||||
{/* 작업지시 생성 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
open={isCreateWorkOrderDialogOpen}
|
||||
onOpenChange={setIsCreateWorkOrderDialogOpen}
|
||||
@@ -685,19 +480,10 @@ export default function ProductionOrderDetailPage() {
|
||||
description={
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="font-medium text-foreground">
|
||||
다음 공정에 대한 작업지시서가 생성됩니다:
|
||||
이 수주에 대한 작업지시서를 자동 생성합니다.
|
||||
</p>
|
||||
{productionOrder?.pendingWorkOrderCount && productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{SAMPLE_PROCESSES.slice(0, productionOrder.pendingWorkOrderCount).map((process) => (
|
||||
<li key={process.id} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
|
||||
{process.name} ({process.quantity}개)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
BOM 기반으로 공정별 작업지시서가 생성됩니다.
|
||||
생성된 작업지시서는 생산팀에서 확인하고 작업을 진행할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
@@ -706,7 +492,7 @@ export default function ProductionOrderDetailPage() {
|
||||
loading={isCreating}
|
||||
/>
|
||||
|
||||
{/* 팝업2: 작업지시 생성 성공 다이얼로그 */}
|
||||
{/* 작업지시 생성 성공 다이얼로그 */}
|
||||
<AlertDialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
@@ -716,24 +502,9 @@ export default function ProductionOrderDetailPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<span className="font-medium text-foreground">
|
||||
{createdWorkOrders.length}개의 작업지시서가 공정별로 자동 생성되었습니다.
|
||||
작업지시서가 자동 생성되었습니다.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-2">생성된 작업지시서:</p>
|
||||
{createdWorkOrders.length > 0 ? (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{createdWorkOrders.map((wo, idx) => (
|
||||
<li key={wo} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||
{wo}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">-</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
작업지시 관리 페이지로 이동합니다.
|
||||
</p>
|
||||
@@ -749,4 +520,4 @@ export default function ProductionOrderDetailPage() {
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,24 +4,20 @@
|
||||
* 생산지시 목록 페이지
|
||||
*
|
||||
* - 수주관리 > 생산지시 보기에서 접근
|
||||
* - 진행 단계 바
|
||||
* - 진행 단계 바 (Order 상태 기반 동적)
|
||||
* - 필터 탭: 전체, 생산대기, 생산중, 생산완료 (TabChip 사용)
|
||||
* - IntegratedListTemplateV2 템플릿 적용
|
||||
* - 서버사이드 페이지네이션
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
@@ -29,7 +25,6 @@ import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
UniversalListPage,
|
||||
@@ -39,136 +34,63 @@ import {
|
||||
} from "@/components/templates/UniversalListPage";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus =
|
||||
| "waiting" // 생산대기
|
||||
| "in_progress" // 생산중
|
||||
| "completed"; // 생산완료
|
||||
|
||||
// 생산지시 데이터 타입
|
||||
interface ProductionOrder {
|
||||
id: string;
|
||||
productionOrderNumber: string; // PO-KD-TS-XXXXXX-XX
|
||||
orderNumber: string; // KD-TS-XXXXXX-XX
|
||||
siteName: string;
|
||||
client: string;
|
||||
quantity: number;
|
||||
dueDate: string;
|
||||
productionOrderDate: string;
|
||||
status: ProductionOrderStatus;
|
||||
workOrderCount: number;
|
||||
}
|
||||
|
||||
// 샘플 생산지시 데이터
|
||||
const SAMPLE_PRODUCTION_ORDERS: ProductionOrder[] = [
|
||||
{
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
client: "호반건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-02-15",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
client: "태영건설(주)",
|
||||
quantity: 10,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
client: "롯데건설(주)",
|
||||
quantity: 1,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
client: "현대건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2026-02-03",
|
||||
productionOrderDate: "2025-12-20",
|
||||
status: "in_progress",
|
||||
workOrderCount: 2,
|
||||
},
|
||||
{
|
||||
id: "PO-005",
|
||||
productionOrderNumber: "PO-KD-BD-251219-34",
|
||||
orderNumber: "KD-BD-251219-34",
|
||||
siteName: "[코레타스프1] 김포 6차 필라테스장",
|
||||
client: "신성플랜(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-01-15",
|
||||
productionOrderDate: "2025-12-19",
|
||||
status: "in_progress",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-006",
|
||||
productionOrderNumber: "PO-KD-TS-250401-29",
|
||||
orderNumber: "KD-TS-250401-29",
|
||||
siteName: "포레나 전주",
|
||||
client: "한화건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2025-05-16",
|
||||
productionOrderDate: "2025-04-01",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-007",
|
||||
productionOrderNumber: "PO-KD-BD-250331-28",
|
||||
orderNumber: "KD-BD-250331-28",
|
||||
siteName: "포레나 수원",
|
||||
client: "포레나건설(주)",
|
||||
quantity: 4,
|
||||
dueDate: "2025-05-15",
|
||||
productionOrderDate: "2025-03-31",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-008",
|
||||
productionOrderNumber: "PO-KD-TS-250314-23",
|
||||
orderNumber: "KD-TS-250314-23",
|
||||
siteName: "자이 흑산파크",
|
||||
client: "GS건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2025-04-28",
|
||||
productionOrderDate: "2025-03-14",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
];
|
||||
import {
|
||||
getProductionOrders,
|
||||
getProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/actions";
|
||||
import type {
|
||||
ProductionOrder,
|
||||
ProductionStatus,
|
||||
ProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// 진행 단계 컴포넌트
|
||||
function ProgressSteps() {
|
||||
const steps = [
|
||||
{ label: "수주확정", active: true, completed: true },
|
||||
{ label: "생산지시", active: true, completed: false },
|
||||
{ label: "작업지시", active: false, completed: false },
|
||||
{ label: "생산", active: false, completed: false },
|
||||
{ label: "검사출하", active: false, completed: false },
|
||||
];
|
||||
function ProgressSteps({ statusCode }: { statusCode?: string }) {
|
||||
const getSteps = () => {
|
||||
// 기본: 생산지시 목록에 있으면 수주확정, 생산지시는 이미 완료
|
||||
const steps = [
|
||||
{ label: "수주확정", completed: true, active: false },
|
||||
{ label: "생산지시", completed: true, active: false },
|
||||
{ label: "작업지시", completed: false, active: false },
|
||||
{ label: "생산", completed: false, active: false },
|
||||
{ label: "검사출하", completed: false, active: false },
|
||||
];
|
||||
|
||||
if (!statusCode) return steps;
|
||||
|
||||
// IN_PROGRESS = 생산대기 (작업지시 배정 진행 중)
|
||||
if (statusCode === "IN_PROGRESS") {
|
||||
steps[2].active = true;
|
||||
}
|
||||
// IN_PRODUCTION = 생산중
|
||||
if (statusCode === "IN_PRODUCTION") {
|
||||
steps[2].completed = true;
|
||||
steps[3].active = true;
|
||||
}
|
||||
// PRODUCED = 생산완료
|
||||
if (statusCode === "PRODUCED") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].active = true;
|
||||
}
|
||||
// SHIPPING = 출하중
|
||||
if (statusCode === "SHIPPING") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].active = true;
|
||||
}
|
||||
// SHIPPED = 출하완료
|
||||
if (statusCode === "SHIPPED") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].completed = true;
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
const steps = getSteps();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
@@ -214,16 +136,16 @@ function ProgressSteps() {
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
function getStatusBadge(status: ProductionStatus) {
|
||||
const config: Record<
|
||||
ProductionOrderStatus,
|
||||
ProductionStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_progress: {
|
||||
in_production: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
@@ -239,79 +161,34 @@ function getStatusBadge(status: ProductionOrderStatus) {
|
||||
// 테이블 컬럼 정의
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: "no", label: "번호", className: "w-[60px] text-center" },
|
||||
{ key: "productionOrderNumber", label: "생산지시번호", className: "min-w-[150px]" },
|
||||
{ key: "orderNumber", label: "수주번호", className: "min-w-[140px]" },
|
||||
{ key: "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: "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: "status", label: "상태", className: "w-[100px]" },
|
||||
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center" },
|
||||
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center", copyable: true },
|
||||
{ key: "actions", label: "작업", className: "w-[100px] text-center" },
|
||||
];
|
||||
|
||||
export default function ProductionOrdersListPage() {
|
||||
const router = useRouter();
|
||||
const [orders, setOrders] = useState<ProductionOrder[]>(SAMPLE_PRODUCTION_ORDERS);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 삭제 확인 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); // 개별 삭제 시 사용
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = orders.filter((item) => {
|
||||
// 탭 필터
|
||||
if (activeTab !== "all") {
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
if (item.status !== statusMap[activeTab]) return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
const [stats, setStats] = useState<ProductionOrderStats>({
|
||||
total: 0,
|
||||
waiting: 0,
|
||||
in_production: 0,
|
||||
completed: 0,
|
||||
});
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// 탭별 건수
|
||||
const tabCounts = {
|
||||
all: orders.length,
|
||||
waiting: orders.filter((i) => i.status === "waiting").length,
|
||||
in_progress: orders.filter((i) => i.status === "in_progress").length,
|
||||
completed: orders.filter((i) => i.status === "completed").length,
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: tabCounts.all },
|
||||
{ value: "waiting", label: "생산대기", count: tabCounts.waiting, color: "yellow" },
|
||||
{ value: "in_progress", label: "생산중", count: tabCounts.in_progress, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: tabCounts.completed, color: "gray" },
|
||||
];
|
||||
// 통계 로드
|
||||
useEffect(() => {
|
||||
getProductionOrderStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
@@ -325,57 +202,13 @@ export default function ProductionOrdersListPage() {
|
||||
router.push(`/sales/order-management-sales/production-orders/${item.id}?mode=view`);
|
||||
};
|
||||
|
||||
// 개별 삭제 다이얼로그 열기
|
||||
const handleDelete = (item: ProductionOrder) => {
|
||||
setDeleteTargetId(item.id);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 일괄 삭제 다이얼로그 열기
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedItems.size > 0) {
|
||||
setDeleteTargetId(null); // 일괄 삭제
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 개수 계산 (개별 삭제 시 1, 일괄 삭제 시 selectedItems.size)
|
||||
const deleteCount = deleteTargetId ? 1 : selectedItems.size;
|
||||
|
||||
// 실제 삭제 실행
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteTargetId) {
|
||||
// 개별 삭제
|
||||
setOrders(orders.filter((o) => o.id !== deleteTargetId));
|
||||
setSelectedItems(new Set([...selectedItems].filter(id => id !== deleteTargetId)));
|
||||
} else {
|
||||
// 일괄 삭제
|
||||
const selectedIds = Array.from(selectedItems);
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTargetId(null);
|
||||
};
|
||||
// 탭 옵션 (통계 기반 동적 카운트)
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: stats.total },
|
||||
{ value: "waiting", label: "생산대기", count: stats.waiting, color: "yellow" },
|
||||
{ value: "in_production", label: "생산중", count: stats.in_production, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: stats.completed, color: "gray" },
|
||||
];
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
@@ -402,22 +235,17 @@ export default function ProductionOrdersListPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
{item.productionOrderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.orderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{item.siteName}
|
||||
</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}개</TableCell>
|
||||
<TableCell>{item.dueDate}</TableCell>
|
||||
<TableCell>{item.productionOrderDate}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell>{item.clientName}</TableCell>
|
||||
<TableCell className="text-center">{formatNumber(item.nodeCount)}개소</TableCell>
|
||||
<TableCell>{item.deliveryDate}</TableCell>
|
||||
<TableCell>{item.productionOrderedAt}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.productionStatus)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.workOrderCount > 0 ? (
|
||||
<Badge variant="outline">{item.workOrderCount}건</Badge>
|
||||
@@ -431,9 +259,6 @@ export default function ProductionOrdersListPage() {
|
||||
<Button variant="ghost" size="sm" onClick={() => handleView(item)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(item)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -463,19 +288,19 @@ export default function ProductionOrdersListPage() {
|
||||
variant="outline"
|
||||
className="bg-blue-50 text-blue-700 font-mono text-xs"
|
||||
>
|
||||
{item.productionOrderNumber}
|
||||
{item.orderNumber}
|
||||
</Badge>
|
||||
{getStatusBadge(item.status)}
|
||||
{getStatusBadge(item.productionStatus)}
|
||||
</>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="수주번호" value={item.orderNumber} />
|
||||
<InfoField label="현장명" value={item.siteName} />
|
||||
<InfoField label="거래처" value={item.client} />
|
||||
<InfoField label="수량" value={`${item.quantity}개`} />
|
||||
<InfoField label="납기" value={item.dueDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderDate} />
|
||||
<InfoField label="거래처" value={item.clientName} />
|
||||
<InfoField label="개소" value={`${formatNumber(item.nodeCount)}개소`} />
|
||||
<InfoField label="납기" value={item.deliveryDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderedAt} />
|
||||
<InfoField
|
||||
label="작업지시"
|
||||
value={item.workOrderCount > 0 ? `${item.workOrderCount}건` : "-"}
|
||||
@@ -497,18 +322,6 @@ export default function ProductionOrdersListPage() {
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -516,6 +329,43 @@ export default function ProductionOrdersListPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// getList API 호출
|
||||
const getList = useCallback(async (params?: { page?: number; pageSize?: number; search?: string; tab?: string }) => {
|
||||
const productionStatus = params?.tab && params.tab !== "all"
|
||||
? (params.tab as ProductionStatus)
|
||||
: undefined;
|
||||
|
||||
const result = await getProductionOrders({
|
||||
search: params?.search,
|
||||
productionStatus,
|
||||
page: params?.page,
|
||||
perPage: params?.pageSize,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 새로고침
|
||||
getProductionOrderStats().then((statsResult) => {
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination?.total || 0,
|
||||
totalPages: result.pagination?.lastPage || 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
error: result.error,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const productionOrderConfig: UniversalListConfig<ProductionOrder> = {
|
||||
title: "생산지시 목록",
|
||||
@@ -525,43 +375,19 @@ export default function ProductionOrdersListPage() {
|
||||
idField: "id",
|
||||
|
||||
actions: {
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: orders,
|
||||
totalCount: orders.length,
|
||||
}),
|
||||
getList,
|
||||
},
|
||||
|
||||
columns: TABLE_COLUMNS,
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
defaultTab: "all",
|
||||
|
||||
searchPlaceholder: "생산지시번호, 수주번호, 현장명 검색...",
|
||||
searchPlaceholder: "수주번호, 현장명, 거래처 검색...",
|
||||
|
||||
itemsPerPage,
|
||||
itemsPerPage: 20,
|
||||
|
||||
clientSideFiltering: true,
|
||||
|
||||
searchFilter: (item, searchValue) => {
|
||||
const term = searchValue.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
},
|
||||
|
||||
tabFilter: (item, tabValue) => {
|
||||
if (tabValue === "all") return true;
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
return item.status === statusMap[tabValue];
|
||||
},
|
||||
clientSideFiltering: false,
|
||||
|
||||
headerActions: () => (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
@@ -580,50 +406,11 @@ export default function ProductionOrdersListPage() {
|
||||
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
|
||||
renderDialogs: () => (
|
||||
<DeleteConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="삭제 확인"
|
||||
description={
|
||||
<>
|
||||
선택한 <strong>{deleteCount}개</strong>의 항목을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
삭제된 항목은 복구할 수 없습니다. 관련된 데이터도 함께 삭제될 수 있습니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<UniversalListPage<ProductionOrder>
|
||||
config={productionOrderConfig}
|
||||
initialData={orders}
|
||||
initialTotalCount={orders.length}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
onToggleSelection: toggleSelection,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
setSelectedItems,
|
||||
getItemId: (item: ProductionOrder) => item.id,
|
||||
}}
|
||||
onTabChange={(value: string) => {
|
||||
setActiveTab(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onSearchChange={setSearchTerm}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +89,7 @@ export default function QuoteDetailPage() {
|
||||
|
||||
v2Data.locations = updatedLocations;
|
||||
} else {
|
||||
// no BOM result to merge
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
112
src/app/[locale]/auto-login/page.tsx
Normal file
112
src/app/[locale]/auto-login/page.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
107
src/app/api/auth/token-login/route.ts
Normal file
107
src/app/api/auth/token-login/route.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,9 +114,21 @@ export async function POST(request: NextRequest) {
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
|
||||
// HTML 설정
|
||||
// 외부 리소스 요청 차단 (이미지는 이미 base64 인라인)
|
||||
await page.setRequestInterception(true);
|
||||
page.on('request', (req) => {
|
||||
const resourceType = req.resourceType();
|
||||
// 이미지/폰트/스타일시트 등 외부 리소스 차단 → 타임아웃 방지
|
||||
if (['image', 'font', 'stylesheet', 'media'].includes(resourceType)) {
|
||||
req.abort();
|
||||
} else {
|
||||
req.continue();
|
||||
}
|
||||
});
|
||||
|
||||
// HTML 설정 (domcontentloaded: 외부 리소스 대기 안 함)
|
||||
await page.setContent(fullHtml, {
|
||||
waitUntil: 'networkidle0',
|
||||
waitUntil: 'domcontentloaded',
|
||||
});
|
||||
|
||||
// 헤더 템플릿 (문서번호, 생성일)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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)
|
||||
@@ -190,14 +191,35 @@ async function proxyRequest(
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const responseData = await backendResponse.text();
|
||||
let responseData = await backendResponse.text();
|
||||
|
||||
clientResponse = new NextResponse(responseData, {
|
||||
status: backendResponse.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
},
|
||||
});
|
||||
// 백엔드가 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 토큰이 갱신되었으면 새 쿠키 설정
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
@@ -39,8 +40,10 @@ import type {
|
||||
} from './types';
|
||||
import {
|
||||
STATUS_SELECT_OPTIONS,
|
||||
COLLECTION_END_REASON_OPTIONS,
|
||||
VENDOR_TYPE_LABELS,
|
||||
} from './types';
|
||||
import type { CollectionEndReason } from './types';
|
||||
import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
@@ -87,6 +90,7 @@ const getEmptyRecord = (): Omit<BadDebtRecord, 'id' | 'createdAt' | 'updatedAt'>
|
||||
assignedManagerId: null,
|
||||
assignedManager: null,
|
||||
settingToggle: true,
|
||||
collectionEndReason: undefined,
|
||||
badDebtCount: 0,
|
||||
badDebts: [],
|
||||
files: [],
|
||||
@@ -134,12 +138,14 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
if (isNewMode) {
|
||||
const result = await createBadDebt(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '등록에 실패했습니다.' };
|
||||
} else {
|
||||
const result = await updateBadDebt(recordId!, formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '수정에 실패했습니다.' };
|
||||
@@ -156,6 +162,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
try {
|
||||
const result = await deleteBadDebt(String(id));
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '삭제에 실패했습니다.' };
|
||||
@@ -264,7 +271,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
}, [router, formData.vendorId]);
|
||||
|
||||
// 파일 다운로드 핸들러
|
||||
const handleFileDownload = useCallback((fileName: string) => {
|
||||
const handleFileDownload = useCallback((_fileName: string) => {
|
||||
// TODO: 실제 다운로드 로직
|
||||
}, []);
|
||||
|
||||
@@ -778,22 +785,47 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(val) => handleChange('status', val as CollectionStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_SELECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(val) => {
|
||||
handleChange('status', val as CollectionStatus);
|
||||
if (val !== 'collectionEnd') {
|
||||
handleChange('collectionEndReason', null);
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_SELECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{formData.status === 'collectionEnd' && (
|
||||
<Select
|
||||
value={formData.collectionEndReason || ''}
|
||||
onValueChange={(val) => handleChange('collectionEndReason', val as CollectionEndReason)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className="bg-white">
|
||||
<SelectValue placeholder="종료사유 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLLECTION_END_REASON_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 연체일수 */}
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { BadDebtDetail } from './BadDebtDetail';
|
||||
import { getBadDebtById } from './actions';
|
||||
import type { BadDebtRecord } from './types';
|
||||
@@ -26,7 +26,6 @@ interface BadDebtDetailClientV2Props {
|
||||
const BASE_PATH = '/ko/accounting/bad-debt-collection';
|
||||
|
||||
export function BadDebtDetailClientV2({ recordId, initialMode }: BadDebtDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL 쿼리에서 모드 결정
|
||||
|
||||
@@ -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, BadDebtItem, CollectionStatus } from './types';
|
||||
import type { BadDebtRecord, CollectionStatus } from './types';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
|
||||
@@ -72,8 +72,9 @@ function mapApiStatusToFrontend(apiStatus: string): CollectionStatus {
|
||||
switch (apiStatus) {
|
||||
case 'collecting': return 'collecting';
|
||||
case 'legal_action': return 'legalAction';
|
||||
case 'recovered': return 'recovered';
|
||||
case 'bad_debt': return 'badDebt';
|
||||
case 'recovered':
|
||||
case 'bad_debt':
|
||||
case 'collection_end': return 'collectionEnd';
|
||||
default: return 'collecting';
|
||||
}
|
||||
}
|
||||
@@ -82,8 +83,7 @@ function mapFrontendStatusToApi(status: CollectionStatus): string {
|
||||
switch (status) {
|
||||
case 'collecting': return 'collecting';
|
||||
case 'legalAction': return 'legal_action';
|
||||
case 'recovered': return 'recovered';
|
||||
case 'badDebt': return 'bad_debt';
|
||||
case 'collectionEnd': return 'collection_end';
|
||||
default: return 'collecting';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,10 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { AlertTriangle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -49,13 +51,14 @@ import { deleteBadDebt, toggleBadDebt } from './actions';
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
|
||||
{ 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: '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: 'managerName', label: '담당자', className: 'w-[100px]', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'setting', label: '설정', className: 'text-center w-[80px]' },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
];
|
||||
|
||||
// ===== Props 타입 정의 =====
|
||||
@@ -65,8 +68,7 @@ interface BadDebtCollectionProps {
|
||||
total_amount: number;
|
||||
collecting_amount: number;
|
||||
legal_action_amount: number;
|
||||
recovered_amount: number;
|
||||
bad_debt_amount: number;
|
||||
collection_end_amount: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -132,7 +134,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
totalAmount: initialSummary.total_amount,
|
||||
collectingAmount: initialSummary.collecting_amount,
|
||||
legalActionAmount: initialSummary.legal_action_amount,
|
||||
recoveredAmount: initialSummary.recovered_amount,
|
||||
collectionEndAmount: initialSummary.collection_end_amount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,11 +146,11 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
const legalActionAmount = data
|
||||
.filter((d) => d.status === 'legalAction')
|
||||
.reduce((sum, d) => sum + d.debtAmount, 0);
|
||||
const recoveredAmount = data
|
||||
.filter((d) => d.status === 'recovered')
|
||||
const collectionEndAmount = data
|
||||
.filter((d) => d.status === 'collectionEnd')
|
||||
.reduce((sum, d) => sum + d.debtAmount, 0);
|
||||
|
||||
return { totalAmount, collectingAmount, legalActionAmount, recoveredAmount };
|
||||
return { totalAmount, collectingAmount, legalActionAmount, collectionEndAmount };
|
||||
}, [data, initialSummary]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
@@ -175,6 +177,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBadDebt(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
setData((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
@@ -335,7 +338,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
},
|
||||
{
|
||||
label: '회수완료',
|
||||
value: `${formatNumber(statsData.recoveredAmount)}원`,
|
||||
value: `${formatNumber(statsData.collectionEndAmount)}원`,
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
@@ -390,6 +393,27 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
disabled={isPending}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 작업 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => router.push(`/ko/accounting/bad-debt-collection/${item.id}?mode=edit`)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 text-red-500 hover:text-red-700"
|
||||
onClick={() => handlers.onDelete?.(item)}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
// ===== 악성채권 추심관리 타입 정의 =====
|
||||
|
||||
// 추심 상태
|
||||
export type CollectionStatus = 'collecting' | 'legalAction' | 'recovered' | 'badDebt';
|
||||
export type CollectionStatus = 'collecting' | 'legalAction' | 'collectionEnd';
|
||||
|
||||
// 추심종료 사유
|
||||
export type CollectionEndReason = 'recovered' | 'badDebt';
|
||||
|
||||
export const COLLECTION_END_REASON_OPTIONS: { value: CollectionEndReason; label: string }[] = [
|
||||
{ value: 'recovered', label: '회수완료' },
|
||||
{ value: 'badDebt', label: '대손처리' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest';
|
||||
@@ -70,6 +78,7 @@ export interface BadDebtRecord {
|
||||
debtAmount: number; // 총 미수금액
|
||||
badDebtCount: number; // 악성채권 건수
|
||||
status: CollectionStatus; // 대표 상태 (가장 최근)
|
||||
collectionEndReason?: CollectionEndReason; // 추심종료 사유 (status === 'collectionEnd'일 때)
|
||||
overdueDays: number; // 최대 연체일수
|
||||
overdueToggle: boolean;
|
||||
occurrenceDate: string;
|
||||
|
||||
@@ -51,25 +51,40 @@ 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 },
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// ===== 기본 Summary =====
|
||||
@@ -97,7 +112,7 @@ export function BankTransactionInquiry() {
|
||||
|
||||
// 필터 상태
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [sortOption] = useState<SortOption>('latest');
|
||||
const [accountCategoryFilter, setAccountCategoryFilter] = useState<AccountCategoryFilter>('all');
|
||||
const [financialInstitutionFilter, setFinancialInstitutionFilter] = useState('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -226,22 +241,45 @@ export function BankTransactionInquiry() {
|
||||
}
|
||||
}, [localChanges, loadData]);
|
||||
|
||||
// 엑셀 다운로드
|
||||
// 엑셀 다운로드 (프론트 xlsx 생성)
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
const result = await exportBankTransactionsExcel({
|
||||
startDate,
|
||||
endDate,
|
||||
accountCategory: accountCategoryFilter,
|
||||
financialInstitution: financialInstitutionFilter,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
window.open(result.data.downloadUrl, '_blank');
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: BankTransaction[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const result = await getBankTransactionList({
|
||||
startDate,
|
||||
endDate,
|
||||
accountCategory: accountCategoryFilter,
|
||||
financialInstitution: financialInstitutionFilter,
|
||||
perPage: 100,
|
||||
page,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel({
|
||||
data: allData as (BankTransaction & Record<string, unknown>)[],
|
||||
columns: excelColumns,
|
||||
filename: '계좌입출금내역',
|
||||
sheetName: '입출금내역',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);
|
||||
|
||||
|
||||
@@ -1,99 +1,65 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { toast } from 'sonner';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { billConfig } from './billConfig';
|
||||
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
|
||||
import { apiDataToFormData, transformFormDataToApi } from './types';
|
||||
import type { BillApiData } from './types';
|
||||
import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions';
|
||||
import { useBillForm } from './hooks/useBillForm';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useBillConditions } from './hooks/useBillConditions';
|
||||
import {
|
||||
BILL_TYPE_OPTIONS,
|
||||
getBillStatusOptions,
|
||||
} from './types';
|
||||
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
|
||||
|
||||
// ===== 새 훅 import =====
|
||||
BasicInfoSection,
|
||||
ElectronicBillSection,
|
||||
ExchangeBillSection,
|
||||
DiscountInfoSection,
|
||||
EndorsementSection,
|
||||
CollectionSection,
|
||||
HistorySection,
|
||||
RenewalSection,
|
||||
RecourseSection,
|
||||
BuybackSection,
|
||||
DishonoredSection,
|
||||
} from './sections';
|
||||
import { useDetailData } from '@/hooks';
|
||||
|
||||
// ===== Props =====
|
||||
interface BillDetailProps {
|
||||
billId: string;
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
}
|
||||
|
||||
// ===== 거래처 타입 =====
|
||||
interface ClientOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
|
||||
interface BillFormData {
|
||||
billNumber: string;
|
||||
billType: BillType;
|
||||
vendorId: string;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
status: BillStatus;
|
||||
note: string;
|
||||
installments: InstallmentRecord[];
|
||||
}
|
||||
|
||||
const INITIAL_FORM_DATA: BillFormData = {
|
||||
billNumber: '',
|
||||
billType: 'received',
|
||||
vendorId: '',
|
||||
amount: 0,
|
||||
issueDate: '',
|
||||
maturityDate: '',
|
||||
status: 'stored',
|
||||
note: '',
|
||||
installments: [],
|
||||
};
|
||||
|
||||
export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
// 거래처 목록
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
|
||||
// ===== 폼 상태 (통합된 단일 state) =====
|
||||
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
|
||||
// V8 폼 훅
|
||||
const {
|
||||
formData,
|
||||
updateField,
|
||||
handleInstrumentTypeChange,
|
||||
handleDirectionChange,
|
||||
addInstallment,
|
||||
removeInstallment,
|
||||
updateInstallment,
|
||||
setFormDataFull,
|
||||
} = useBillForm();
|
||||
|
||||
// ===== 폼 필드 업데이트 헬퍼 =====
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(
|
||||
field: K,
|
||||
value: BillFormData[K]
|
||||
) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
// 조건부 표시 플래그
|
||||
const conditions = useBillConditions(formData);
|
||||
|
||||
// ===== 거래처 목록 로드 =====
|
||||
// 거래처 목록 로드
|
||||
useEffect(() => {
|
||||
async function loadClients() {
|
||||
const result = await getClients();
|
||||
@@ -104,41 +70,30 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
loadClients();
|
||||
}, []);
|
||||
|
||||
// ===== 새 훅: useDetailData로 데이터 로딩 =====
|
||||
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
|
||||
// API 데이터 로딩 (BillApiData 그대로)
|
||||
const fetchBillWrapper = useCallback(
|
||||
(id: string | number) => getBill(String(id)),
|
||||
(id: string | number) => getBillRaw(String(id)),
|
||||
[]
|
||||
);
|
||||
|
||||
const {
|
||||
data: billData,
|
||||
data: billApiData,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
} = useDetailData<BillRecord>(
|
||||
} = useDetailData<BillApiData>(
|
||||
billId !== 'new' ? billId : null,
|
||||
fetchBillWrapper,
|
||||
{ skip: isNewMode }
|
||||
);
|
||||
|
||||
// ===== 데이터 로드 시 폼에 반영 =====
|
||||
// API 데이터 → V8 폼 데이터로 변환
|
||||
useEffect(() => {
|
||||
if (billData) {
|
||||
setFormData({
|
||||
billNumber: billData.billNumber,
|
||||
billType: billData.billType,
|
||||
vendorId: billData.vendorId,
|
||||
amount: billData.amount,
|
||||
issueDate: billData.issueDate,
|
||||
maturityDate: billData.maturityDate,
|
||||
status: billData.status,
|
||||
note: billData.note,
|
||||
installments: billData.installments,
|
||||
});
|
||||
if (billApiData) {
|
||||
setFormDataFull(apiDataToFormData(billApiData));
|
||||
}
|
||||
}, [billData]);
|
||||
}, [billApiData, setFormDataFull]);
|
||||
|
||||
// ===== 로드 에러 처리 =====
|
||||
// 로드 에러
|
||||
useEffect(() => {
|
||||
if (loadError) {
|
||||
toast.error(loadError);
|
||||
@@ -146,43 +101,21 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
}
|
||||
}, [loadError, router]);
|
||||
|
||||
// ===== 유효성 검사 함수 =====
|
||||
// 유효성 검사
|
||||
const validateForm = useCallback((): { valid: boolean; error?: string } => {
|
||||
if (!formData.billNumber.trim()) {
|
||||
return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
}
|
||||
if (!formData.vendorId) {
|
||||
return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
}
|
||||
if (formData.amount <= 0) {
|
||||
return { valid: false, error: '금액을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.issueDate) {
|
||||
return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
}
|
||||
if (!formData.maturityDate) {
|
||||
return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
}
|
||||
|
||||
// 차수 유효성 검사
|
||||
for (let i = 0; i < formData.installments.length; i++) {
|
||||
const inst = formData.installments[i];
|
||||
if (!inst.date) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
|
||||
}
|
||||
if (inst.amount <= 0) {
|
||||
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.billNumber.trim()) return { valid: false, error: '어음번호를 입력해주세요.' };
|
||||
const vendorId = conditions.isReceived ? formData.vendor : formData.payee;
|
||||
if (!vendorId) return { valid: false, error: '거래처를 선택해주세요.' };
|
||||
if (formData.amount <= 0) return { valid: false, error: '금액을 입력해주세요.' };
|
||||
if (!formData.issueDate) return { valid: false, error: '발행일을 입력해주세요.' };
|
||||
if (conditions.isBill && !formData.maturityDate) return { valid: false, error: '만기일을 입력해주세요.' };
|
||||
return { valid: true };
|
||||
}, [formData]);
|
||||
}, [formData, conditions.isReceived, conditions.isBill]);
|
||||
|
||||
// ===== 제출 상태 =====
|
||||
// 제출
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
const validation = validateForm();
|
||||
if (!validation.valid) {
|
||||
@@ -192,28 +125,30 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const billData: Partial<BillRecord> = {
|
||||
...formData,
|
||||
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
|
||||
};
|
||||
const vendorName = clients.find(c => c.id === (conditions.isReceived ? formData.vendor : formData.payee))?.name || '';
|
||||
const apiPayload = transformFormDataToApi(formData, vendorName);
|
||||
|
||||
if (isNewMode) {
|
||||
const result = await createBill(billData);
|
||||
const result = await createBillRaw(apiPayload);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success('등록되었습니다.');
|
||||
router.push('/ko/accounting/bills');
|
||||
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
|
||||
return { success: false, error: '' };
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
return await updateBill(String(billId), billData);
|
||||
const result = await updateBillRaw(String(billId), apiPayload);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, clients, isNewMode, billId, validateForm, router]);
|
||||
}, [formData, clients, conditions.isReceived, isNewMode, billId, validateForm, router]);
|
||||
|
||||
// ===== 삭제 핸들러 (결과만 반환, 토스트/리다이렉트는 IntegratedDetailTemplate에 위임) =====
|
||||
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
@@ -223,284 +158,91 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
}
|
||||
}, [billId]);
|
||||
|
||||
// ===== 차수 관리 핸들러 =====
|
||||
const handleAddInstallment = useCallback(() => {
|
||||
const newInstallment: InstallmentRecord = {
|
||||
id: `inst-${Date.now()}`,
|
||||
date: '',
|
||||
amount: 0,
|
||||
note: '',
|
||||
};
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [...prev.installments, newInstallment],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleUpdateInstallment = useCallback((
|
||||
id: string,
|
||||
field: keyof InstallmentRecord,
|
||||
value: string | number
|
||||
) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 상태 옵션 (구분에 따라 변경) =====
|
||||
const statusOptions = useMemo(
|
||||
() => getBillStatusOptions(formData.billType),
|
||||
[formData.billType]
|
||||
);
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
// 폼 콘텐츠 렌더링
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* 기본 정보 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billNumber">
|
||||
어음번호 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="billNumber"
|
||||
value={formData.billNumber}
|
||||
onChange={(e) => updateField('billNumber', e.target.value)}
|
||||
placeholder="어음번호를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 1. 기본 정보 */}
|
||||
<BasicInfoSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
clients={clients}
|
||||
conditions={conditions}
|
||||
onInstrumentTypeChange={handleInstrumentTypeChange}
|
||||
onDirectionChange={handleDirectionChange}
|
||||
/>
|
||||
|
||||
{/* 구분 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="billType">
|
||||
구분 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.billType}
|
||||
onValueChange={(v) => updateField('billType', v as BillType)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 2. 전자어음 정보 */}
|
||||
{conditions.showElectronic && (
|
||||
<ElectronicBillSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vendorId">
|
||||
거래처 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.vendorId}
|
||||
onValueChange={(v) => updateField('vendorId', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map((client) => (
|
||||
<SelectItem key={client.id} value={client.id}>
|
||||
{client.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 3. 환어음 정보 */}
|
||||
{conditions.showExchangeBill && (
|
||||
<ExchangeBillSection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
showAcceptanceRefusal={conditions.showAcceptanceRefusal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount">
|
||||
금액 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<CurrencyInput
|
||||
id="amount"
|
||||
value={formData.amount}
|
||||
onChange={(value) => updateField('amount', value ?? 0)}
|
||||
placeholder="금액을 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 4. 할인 정보 */}
|
||||
{conditions.showDiscount && (
|
||||
<DiscountInfoSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="issueDate">
|
||||
발행일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.issueDate}
|
||||
onChange={(date) => updateField('issueDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 5. 배서양도 정보 */}
|
||||
{conditions.showEndorsement && (
|
||||
<EndorsementSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 만기일 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maturityDate">
|
||||
만기일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.maturityDate}
|
||||
onChange={(date) => updateField('maturityDate', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
{/* 6. 추심 정보 */}
|
||||
{conditions.showCollection && (
|
||||
<CollectionSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">
|
||||
상태 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => updateField('status', v as BillStatus)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* 7. 이력 관리 (받을어음만) */}
|
||||
{conditions.isReceived && (
|
||||
<HistorySection
|
||||
formData={formData}
|
||||
updateField={updateField}
|
||||
isViewMode={isViewMode}
|
||||
isElectronic={conditions.isElectronic}
|
||||
maxSplitCount={conditions.maxSplitCount}
|
||||
onAddInstallment={addInstallment}
|
||||
onRemoveInstallment={removeInstallment}
|
||||
onUpdateInstallment={updateInstallment}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">비고</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={formData.note}
|
||||
onChange={(e) => updateField('note', e.target.value)}
|
||||
placeholder="비고를 입력해주세요"
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 8. 개서 정보 */}
|
||||
{conditions.showRenewal && (
|
||||
<RenewalSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 차수 관리 섹션 */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span className="text-red-500">*</span> 차수 관리
|
||||
</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddInstallment}
|
||||
className="text-orange-500 border-orange-300 hover:bg-orange-50"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="min-w-[130px]">일자</TableHead>
|
||||
<TableHead className="min-w-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
|
||||
등록된 차수가 없습니다
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.installments.map((inst, index) => (
|
||||
<TableRow key={inst.id}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker
|
||||
value={inst.date}
|
||||
onChange={(date) => handleUpdateInstallment(inst.id, 'date', date)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput
|
||||
value={inst.amount}
|
||||
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={inst.note}
|
||||
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||
onClick={() => handleRemoveInstallment(inst.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* 9. 소구 정보 */}
|
||||
{conditions.showRecourse && (
|
||||
<RecourseSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 10. 환매 정보 */}
|
||||
{conditions.showBuyback && (
|
||||
<BuybackSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
|
||||
{/* 11. 부도 정보 */}
|
||||
{conditions.showDishonored && (
|
||||
<DishonoredSection formData={formData} updateField={updateField} isViewMode={isViewMode} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// ===== 템플릿 모드 및 동적 설정 =====
|
||||
// 템플릿 설정
|
||||
const templateMode = isNewMode ? 'create' : mode;
|
||||
const dynamicConfig = {
|
||||
...billConfig,
|
||||
title: isViewMode ? '어음 상세' : '어음',
|
||||
title: isViewMode ? '어음/수표 상세' : '어음/수표',
|
||||
actions: {
|
||||
...billConfig.actions,
|
||||
submitLabel: isNewMode ? '등록' : '저장',
|
||||
|
||||
@@ -10,20 +10,19 @@
|
||||
* - tableHeaderActions: 거래처, 구분, 상태 필터
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
Save,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
@@ -32,8 +31,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
UniversalListPage,
|
||||
type UniversalListConfig,
|
||||
@@ -45,7 +42,6 @@ import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
BillRecord,
|
||||
BillType,
|
||||
BillStatus,
|
||||
SortOption,
|
||||
} from './types';
|
||||
@@ -54,6 +50,8 @@ 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';
|
||||
@@ -83,32 +81,15 @@ export function BillManagementClient({
|
||||
const [pagination, setPagination] = useState(initialPagination);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [sortOption] = 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');
|
||||
|
||||
@@ -148,6 +129,16 @@ export function BillManagementClient({
|
||||
}
|
||||
}, [searchQuery, billTypeFilter, statusFilter, vendorFilter, startDate, endDate, sortOption, itemsPerPage]);
|
||||
|
||||
// ===== 필터 변경 시 자동 재조회 =====
|
||||
const isInitialMount = useRef(true);
|
||||
useEffect(() => {
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
return;
|
||||
}
|
||||
loadData(1);
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 체크박스 핸들러 =====
|
||||
const toggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems(prev => {
|
||||
@@ -181,13 +172,13 @@ export function BillManagementClient({
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'billNumber', label: '어음번호', sortable: true },
|
||||
{ key: 'billNumber', label: '어음번호', sortable: true, copyable: true },
|
||||
{ key: 'billType', label: '구분', className: 'text-center', sortable: 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: '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: 'status', label: '상태', className: 'text-center', sortable: true },
|
||||
], []);
|
||||
|
||||
@@ -273,15 +264,15 @@ export function BillManagementClient({
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSave = useCallback(async () => {
|
||||
// ===== 상태 변경 핸들러 =====
|
||||
const handleStatusChange = useCallback(async () => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('선택된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (statusFilter === 'all') {
|
||||
toast.warning('상태를 선택해주세요.');
|
||||
if (!targetStatus) {
|
||||
toast.warning('변경할 상태를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,21 +280,28 @@ export function BillManagementClient({
|
||||
let successCount = 0;
|
||||
|
||||
for (const id of selectedItems) {
|
||||
const result = await updateBillStatus(id, statusFilter as BillStatus);
|
||||
const result = await updateBillStatus(id, targetStatus as BillStatus);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount}건이 저장되었습니다.`);
|
||||
invalidateDashboard('bill');
|
||||
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
|
||||
loadData(currentPage);
|
||||
setSelectedItems(new Set());
|
||||
setTargetStatus('');
|
||||
} else {
|
||||
toast.error('저장에 실패했습니다.');
|
||||
toast.error('상태 변경에 실패했습니다.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [selectedItems, statusFilter, loadData, currentPage]);
|
||||
}, [selectedItems, targetStatus, loadData, currentPage]);
|
||||
|
||||
// 구분에 따른 상태 옵션
|
||||
const statusChangeOptions = useMemo(() => {
|
||||
return billTypeFilter === 'issued' ? ISSUED_BILL_STATUS_OPTIONS : RECEIVED_BILL_STATUS_OPTIONS;
|
||||
}, [billTypeFilter]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<BillRecord> = useMemo(
|
||||
@@ -326,6 +324,25 @@ export function BillManagementClient({
|
||||
totalCount: pagination.total,
|
||||
};
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
await loadData(currentPage);
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 삭제 확인 메시지
|
||||
deleteConfirmMessage: {
|
||||
title: '어음 삭제',
|
||||
description: '이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
@@ -348,32 +365,8 @@ export function BillManagementClient({
|
||||
);
|
||||
},
|
||||
|
||||
// 모바일 필터 설정
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'vendorFilter',
|
||||
label: '거래처',
|
||||
type: 'single',
|
||||
options: vendorOptions.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'billType',
|
||||
label: '구분',
|
||||
type: 'single',
|
||||
options: BILL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: '상태',
|
||||
type: 'single',
|
||||
options: BILL_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all'),
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
vendorFilter: vendorFilter,
|
||||
billType: billTypeFilter,
|
||||
status: statusFilter,
|
||||
},
|
||||
// 모바일 필터 설정 (tableHeaderActions와 중복 방지를 위해 비워둠)
|
||||
filterConfig: [],
|
||||
filterTitle: '어음 필터',
|
||||
|
||||
// 날짜 선택기
|
||||
@@ -392,42 +385,28 @@ export function BillManagementClient({
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션: 수취/발행 라디오 + 상태 선택 + 저장
|
||||
// 모바일: 라디오/상태필터는 숨기고 저장만 표시 (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" />
|
||||
저장
|
||||
// 선택 시 상태 변경 액션
|
||||
selectionActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={targetStatus} onValueChange={setTargetStatus}>
|
||||
<SelectTrigger className="min-w-[130px] w-auto h-8">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusChangeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStatusChange}
|
||||
disabled={!targetStatus || isLoading}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
상태변경
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
@@ -448,7 +427,7 @@ export function BillManagementClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={billTypeFilter} onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}>
|
||||
<Select value={billTypeFilter} onValueChange={setBillTypeFilter}>
|
||||
<SelectTrigger className="min-w-[100px] w-auto">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
@@ -461,7 +440,7 @@ export function BillManagementClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={(value) => { setStatusFilter(value); loadData(1); }}>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="min-w-[110px] w-auto">
|
||||
<SelectValue placeholder="보관중" />
|
||||
</SelectTrigger>
|
||||
@@ -493,7 +472,10 @@ export function BillManagementClient({
|
||||
isLoading,
|
||||
router,
|
||||
loadData,
|
||||
handleSave,
|
||||
currentPage,
|
||||
handleStatusChange,
|
||||
statusChangeOptions,
|
||||
targetStatus,
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
]
|
||||
@@ -519,14 +501,6 @@ export function BillManagementClient({
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialog.single.isOpen}
|
||||
onOpenChange={deleteDialog.single.onOpenChange}
|
||||
onConfirm={deleteDialog.single.confirm}
|
||||
title="어음 삭제"
|
||||
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
|
||||
loading={deleteDialog.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ interface BillSummaryApiData {
|
||||
// ===== 어음 목록 조회 =====
|
||||
export async function getBills(params: {
|
||||
search?: string; billType?: string; status?: string; clientId?: string;
|
||||
isElectronic?: boolean; issueStartDate?: string; issueEndDate?: string;
|
||||
isElectronic?: boolean; instrumentType?: string; medium?: string;
|
||||
issueStartDate?: string; issueEndDate?: string;
|
||||
maturityStartDate?: string; maturityEndDate?: string;
|
||||
sortBy?: string; sortDir?: string; perPage?: number; page?: number;
|
||||
}) {
|
||||
@@ -30,6 +31,8 @@ export async function getBills(params: {
|
||||
status: params.status && params.status !== 'all' ? params.status : undefined,
|
||||
client_id: params.clientId,
|
||||
is_electronic: params.isElectronic,
|
||||
instrument_type: params.instrumentType && params.instrumentType !== 'all' ? params.instrumentType : undefined,
|
||||
medium: params.medium && params.medium !== 'all' ? params.medium : undefined,
|
||||
issue_start_date: params.issueStartDate,
|
||||
issue_end_date: params.issueEndDate,
|
||||
maturity_start_date: params.maturityStartDate,
|
||||
@@ -124,10 +127,38 @@ 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', { per_page: 100 }),
|
||||
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
|
||||
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
|
||||
type ClientApi = { id: number; name: string };
|
||||
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
|
||||
|
||||
@@ -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: '이 어음/수표를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
178
src/components/accounting/BillManagement/constants.ts
Normal file
178
src/components/accounting/BillManagement/constants.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
// ===== 증권종류 =====
|
||||
export const INSTRUMENT_TYPE_OPTIONS = [
|
||||
{ value: 'promissory', label: '약속어음' },
|
||||
{ value: 'exchange', label: '환어음' },
|
||||
{ value: 'cashierCheck', label: '자기앞수표 (가게수표)' },
|
||||
{ value: 'currentCheck', label: '당좌수표' },
|
||||
] as const;
|
||||
|
||||
// ===== 거래방향 =====
|
||||
export const DIRECTION_OPTIONS = [
|
||||
{ value: 'received', label: '수취 (받을어음)' },
|
||||
{ value: 'issued', label: '발행 (지급어음)' },
|
||||
] as const;
|
||||
|
||||
// ===== 전자/지류 =====
|
||||
export const MEDIUM_OPTIONS = [
|
||||
{ value: 'electronic', label: '전자' },
|
||||
{ value: 'paper', label: '지류 (종이)' },
|
||||
] as const;
|
||||
|
||||
// ===== 배서 여부 =====
|
||||
export const ENDORSEMENT_OPTIONS = [
|
||||
{ value: 'endorsable', label: '배서 가능' },
|
||||
{ value: 'nonEndorsable', label: '배서 불가 (배서금지어음)' },
|
||||
] as const;
|
||||
|
||||
// ===== 어음구분 =====
|
||||
export const BILL_CATEGORY_OPTIONS = [
|
||||
{ value: 'commercial', label: '상업어음 (매출채권)' },
|
||||
{ value: 'other', label: '기타어음 (대여금/미수금)' },
|
||||
] as const;
|
||||
|
||||
// ===== 받을어음 - 결제상태 (어음용) =====
|
||||
export const RECEIVED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'endorsed', label: '배서양도' },
|
||||
{ value: 'discounted', label: '할인' },
|
||||
{ value: 'collected', label: '추심' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityDeposit', label: '만기입금' },
|
||||
{ value: 'paymentComplete', label: '결제완료' },
|
||||
{ value: 'renewed', label: '개서 (만기연장)' },
|
||||
{ value: 'recourse', label: '소구 (배서어음 상환)' },
|
||||
{ value: 'buyback', label: '환매 (할인어음 부도)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 받을수표 - 결제상태 (수표용) =====
|
||||
export const RECEIVED_CHECK_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'endorsed', label: '배서양도' },
|
||||
{ value: 'collected', label: '추심' },
|
||||
{ value: 'deposited', label: '추심입금' },
|
||||
{ value: 'paymentComplete', label: '결제완료 (제시입금)' },
|
||||
{ value: 'recourse', label: '소구 (수표법 제39조)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 지급어음 - 지급상태 =====
|
||||
export const ISSUED_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '보관중' },
|
||||
{ value: 'maturityAlert', label: '만기임박 (7일전)' },
|
||||
{ value: 'maturityPayment', label: '만기결제' },
|
||||
{ value: 'paid', label: '결제완료' },
|
||||
{ value: 'renewed', label: '개서 (만기연장)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 지급수표 - 지급상태 =====
|
||||
export const ISSUED_CHECK_STATUS_OPTIONS = [
|
||||
{ value: 'stored', label: '미결제' },
|
||||
{ value: 'paid', label: '결제완료 (제시출금)' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
] as const;
|
||||
|
||||
// ===== 결제방법 =====
|
||||
export const PAYMENT_METHOD_OPTIONS = [
|
||||
{ value: 'autoTransfer', label: '만기자동이체' },
|
||||
{ value: 'currentAccount', label: '당좌결제' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 부도사유 =====
|
||||
export const DISHONOR_REASON_OPTIONS = [
|
||||
{ value: 'insufficient_funds', label: '자금부족 (1호 부도)' },
|
||||
{ value: 'trading_suspension', label: '거래정지처분 (2호 부도)' },
|
||||
{ value: 'formal_defect', label: '형식불비' },
|
||||
{ value: 'signature_mismatch', label: '서명/인감 불일치' },
|
||||
{ value: 'expired', label: '제시기간 경과' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 이력 처리구분 =====
|
||||
export const HISTORY_TYPE_OPTIONS = [
|
||||
{ value: 'received', label: '수취' },
|
||||
{ value: 'endorsement', label: '배서양도' },
|
||||
{ value: 'splitEndorsement', label: '분할배서' },
|
||||
{ value: 'collection', label: '추심의뢰' },
|
||||
{ value: 'collectionDeposit', label: '추심입금' },
|
||||
{ value: 'discount', label: '할인' },
|
||||
{ value: 'maturityDeposit', label: '만기입금' },
|
||||
{ value: 'dishonored', label: '부도' },
|
||||
{ value: 'recourse', label: '소구' },
|
||||
{ value: 'buyback', label: '환매' },
|
||||
{ value: 'renewal', label: '개서' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 배서차수 (지류: 4차, 전자: 20차) =====
|
||||
export const ENDORSEMENT_ORDER_PAPER = [
|
||||
{ value: '1', label: '1차 (발행인 직접수취)' },
|
||||
{ value: '2', label: '2차 (1개 업체 경유)' },
|
||||
{ value: '3', label: '3차 (2개 업체 경유)' },
|
||||
{ value: '4', label: '4차 (3개 업체 경유)' },
|
||||
] as const;
|
||||
|
||||
export const ENDORSEMENT_ORDER_ELECTRONIC = Array.from({ length: 20 }, (_, i) => ({
|
||||
value: String(i + 1),
|
||||
label: i === 0 ? '1차 (발행인 직접수취)' : `${i + 1}차 (${i}개 업체 경유)`,
|
||||
}));
|
||||
|
||||
// ===== 보관장소 =====
|
||||
export const STORAGE_OPTIONS = [
|
||||
{ value: 'safe', label: '금고' },
|
||||
{ value: 'bank', label: '은행 보관' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 지급장소 (어음법 제75조) =====
|
||||
export const PAYMENT_PLACE_OPTIONS = [
|
||||
{ value: 'issuerBank', label: '발행은행 본점' },
|
||||
{ value: 'issuerBankBranch', label: '발행은행 지점' },
|
||||
{ value: 'payerAddress', label: '지급인 주소지' },
|
||||
{ value: 'designatedBank', label: '지정 은행' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 수표 지급장소 (수표법 제3조: 은행만) =====
|
||||
export const PAYMENT_PLACE_CHECK_OPTIONS = [
|
||||
{ value: 'issuerBank', label: '발행은행 본점' },
|
||||
{ value: 'issuerBankBranch', label: '발행은행 지점' },
|
||||
{ value: 'designatedBank', label: '지정 은행' },
|
||||
] as const;
|
||||
|
||||
// ===== 추심결과 =====
|
||||
export const COLLECTION_RESULT_OPTIONS = [
|
||||
{ value: 'success', label: '추심 성공 (입금완료)' },
|
||||
{ value: 'partial', label: '일부 입금' },
|
||||
{ value: 'failed', label: '추심 실패 (부도)' },
|
||||
{ value: 'pending', label: '추심 진행중' },
|
||||
] as const;
|
||||
|
||||
// ===== 소구사유 =====
|
||||
export const RECOURSE_REASON_OPTIONS = [
|
||||
{ value: 'endorsedDishonor', label: '배서양도 어음 부도' },
|
||||
{ value: 'discountDishonor', label: '할인 어음 부도 (환매)' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 인수거절 사유 =====
|
||||
export const ACCEPTANCE_REFUSAL_REASON_OPTIONS = [
|
||||
{ value: 'financialDifficulty', label: '자금 사정 곤란' },
|
||||
{ value: 'disputeOfClaim', label: '채권 분쟁' },
|
||||
{ value: 'amountDispute', label: '금액 이의' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 개서 사유 =====
|
||||
export const RENEWAL_REASON_OPTIONS = [
|
||||
{ value: 'maturityExtension', label: '만기일 연장' },
|
||||
{ value: 'amountChange', label: '금액 변경' },
|
||||
{ value: 'conditionChange', label: '조건 변경' },
|
||||
{ value: 'other', label: '기타' },
|
||||
] as const;
|
||||
|
||||
// ===== 수표 관련 유효 상태 목록 (증권종류 전환 시 검증용) =====
|
||||
export const VALID_CHECK_RECEIVED_STATUSES = ['stored', 'endorsed', 'collected', 'deposited', 'paymentComplete', 'recourse', 'dishonored'];
|
||||
export const VALID_CHECK_ISSUED_STATUSES = ['stored', 'paid', 'dishonored'];
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { BillFormData } from '../types';
|
||||
import {
|
||||
RECEIVED_STATUS_OPTIONS,
|
||||
RECEIVED_CHECK_STATUS_OPTIONS,
|
||||
ISSUED_STATUS_OPTIONS,
|
||||
ISSUED_CHECK_STATUS_OPTIONS,
|
||||
PAYMENT_PLACE_OPTIONS,
|
||||
PAYMENT_PLACE_CHECK_OPTIONS,
|
||||
} from '../constants';
|
||||
|
||||
export function useBillConditions(formData: BillFormData) {
|
||||
return useMemo(() => {
|
||||
const isReceived = formData.direction === 'received';
|
||||
const isIssued = formData.direction === 'issued';
|
||||
const isCheck = formData.instrumentType === 'cashierCheck' || formData.instrumentType === 'currentCheck';
|
||||
const isBill = !isCheck;
|
||||
const canBeElectronic = formData.instrumentType === 'promissory';
|
||||
const isElectronic = formData.medium === 'electronic';
|
||||
|
||||
const currentStatus = isReceived ? formData.receivedStatus : formData.issuedStatus;
|
||||
|
||||
// 조건부 섹션 표시 플래그
|
||||
const showElectronic = isElectronic;
|
||||
const showExchangeBill = formData.instrumentType === 'exchange';
|
||||
const showDiscount = isReceived && formData.isDiscounted && isBill;
|
||||
const showEndorsement = isReceived && formData.receivedStatus === 'endorsed';
|
||||
const showCollection = isReceived && formData.receivedStatus === 'collected';
|
||||
const showDishonored = currentStatus === 'dishonored';
|
||||
const showRenewal = currentStatus === 'renewed' && isBill;
|
||||
const showRecourse = isReceived && formData.receivedStatus === 'recourse';
|
||||
const showBuyback = isReceived && formData.receivedStatus === 'buyback' && isBill;
|
||||
const showAcceptanceRefusal = showExchangeBill && formData.acceptanceStatus === 'refused';
|
||||
|
||||
// 현재 증권종류에 맞는 옵션 목록
|
||||
const receivedStatusOptions = isCheck ? RECEIVED_CHECK_STATUS_OPTIONS : RECEIVED_STATUS_OPTIONS;
|
||||
const issuedStatusOptions = isCheck ? ISSUED_CHECK_STATUS_OPTIONS : ISSUED_STATUS_OPTIONS;
|
||||
const paymentPlaceOptions = isCheck ? PAYMENT_PLACE_CHECK_OPTIONS : PAYMENT_PLACE_OPTIONS;
|
||||
|
||||
// 분할배서 최대 횟수
|
||||
const maxSplitCount = isElectronic ? 4 : 10;
|
||||
|
||||
return {
|
||||
isReceived,
|
||||
isIssued,
|
||||
isCheck,
|
||||
isBill,
|
||||
canBeElectronic,
|
||||
isElectronic,
|
||||
currentStatus,
|
||||
showElectronic,
|
||||
showExchangeBill,
|
||||
showDiscount,
|
||||
showEndorsement,
|
||||
showCollection,
|
||||
showDishonored,
|
||||
showRenewal,
|
||||
showRecourse,
|
||||
showBuyback,
|
||||
showAcceptanceRefusal,
|
||||
receivedStatusOptions,
|
||||
issuedStatusOptions,
|
||||
paymentPlaceOptions,
|
||||
maxSplitCount,
|
||||
};
|
||||
}, [formData.direction, formData.instrumentType, formData.medium, formData.isDiscounted, formData.receivedStatus, formData.issuedStatus, formData.acceptanceStatus]);
|
||||
}
|
||||
103
src/components/accounting/BillManagement/hooks/useBillForm.ts
Normal file
103
src/components/accounting/BillManagement/hooks/useBillForm.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { BillFormData } from '../types';
|
||||
import { INITIAL_BILL_FORM_DATA } from '../types';
|
||||
import {
|
||||
VALID_CHECK_RECEIVED_STATUSES,
|
||||
VALID_CHECK_ISSUED_STATUSES,
|
||||
} from '../constants';
|
||||
|
||||
export function useBillForm(initialData?: Partial<BillFormData>) {
|
||||
const [formData, setFormData] = useState<BillFormData>({
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
...initialData,
|
||||
});
|
||||
|
||||
const updateField = useCallback(<K extends keyof BillFormData>(field: K, value: BillFormData[K]) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 증권종류 변경 시 연관 필드 초기화
|
||||
const handleInstrumentTypeChange = useCallback((newType: string) => {
|
||||
setFormData(prev => {
|
||||
const next = { ...prev, instrumentType: newType as BillFormData['instrumentType'] };
|
||||
const isCheckType = newType === 'cashierCheck' || newType === 'currentCheck';
|
||||
|
||||
// 약속어음 외에는 전자 불가 → 지류로 리셋
|
||||
if (newType !== 'promissory' && prev.medium === 'electronic') {
|
||||
next.medium = 'paper';
|
||||
}
|
||||
|
||||
// 수표 전환 시: 만기일, 할인, 관련 필드 리셋
|
||||
if (isCheckType) {
|
||||
next.maturityDate = '';
|
||||
next.isDiscounted = false;
|
||||
if (!VALID_CHECK_RECEIVED_STATUSES.includes(prev.receivedStatus)) {
|
||||
next.receivedStatus = 'stored';
|
||||
}
|
||||
if (!VALID_CHECK_ISSUED_STATUSES.includes(prev.issuedStatus)) {
|
||||
next.issuedStatus = 'stored';
|
||||
}
|
||||
if (prev.paymentPlace === 'payerAddress' || prev.paymentPlace === 'other') {
|
||||
next.paymentPlace = '';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 거래방향 변경 시 상태 초기화
|
||||
const handleDirectionChange = useCallback((newDirection: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
direction: newDirection as BillFormData['direction'],
|
||||
receivedStatus: 'stored',
|
||||
issuedStatus: 'stored',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 이력 관리
|
||||
const addInstallment = useCallback(() => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: [
|
||||
...prev.installments,
|
||||
{ id: `inst-${Date.now()}`, date: '', type: 'other', amount: 0, counterparty: '', note: '' },
|
||||
],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const removeInstallment = useCallback((id: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.filter(inst => inst.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateInstallment = useCallback((id: string, field: string, value: string | number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
installments: prev.installments.map(inst =>
|
||||
inst.id === id ? { ...inst, [field]: value } : inst
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 폼 전체 덮어쓰기 (API 데이터 로드 시)
|
||||
const setFormDataFull = useCallback((data: BillFormData) => {
|
||||
setFormData(data);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
formData,
|
||||
updateField,
|
||||
handleInstrumentTypeChange,
|
||||
handleDirectionChange,
|
||||
addInstallment,
|
||||
removeInstallment,
|
||||
updateInstallment,
|
||||
setFormDataFull,
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { getBills, deleteBill, updateBillStatus } from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import { extractUniqueOptions } from '../shared';
|
||||
import {
|
||||
@@ -61,13 +62,13 @@ import {
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'billNumber', label: '어음번호', sortable: true },
|
||||
{ key: 'billNumber', label: '어음번호', sortable: true, copyable: true },
|
||||
{ key: 'billType', label: '구분', className: 'text-center', sortable: 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: '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: 'status', label: '상태', className: 'text-center', sortable: true },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
];
|
||||
@@ -93,7 +94,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);
|
||||
@@ -209,6 +210,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
|
||||
await loadBills();
|
||||
}
|
||||
@@ -247,6 +249,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
// 서버에서 재조회 (pagination 메타데이터 포함)
|
||||
await loadBills();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import {
|
||||
INSTRUMENT_TYPE_OPTIONS,
|
||||
DIRECTION_OPTIONS,
|
||||
MEDIUM_OPTIONS,
|
||||
ENDORSEMENT_OPTIONS,
|
||||
BILL_CATEGORY_OPTIONS,
|
||||
STORAGE_OPTIONS,
|
||||
PAYMENT_METHOD_OPTIONS,
|
||||
ENDORSEMENT_ORDER_PAPER,
|
||||
ENDORSEMENT_ORDER_ELECTRONIC,
|
||||
} from '../constants';
|
||||
|
||||
interface BasicInfoSectionProps extends SectionProps {
|
||||
clients: { id: string; name: string }[];
|
||||
conditions: {
|
||||
isReceived: boolean;
|
||||
isIssued: boolean;
|
||||
isCheck: boolean;
|
||||
isBill: boolean;
|
||||
canBeElectronic: boolean;
|
||||
isElectronic: boolean;
|
||||
receivedStatusOptions: readonly { value: string; label: string }[];
|
||||
issuedStatusOptions: readonly { value: string; label: string }[];
|
||||
paymentPlaceOptions: readonly { value: string; label: string }[];
|
||||
};
|
||||
onInstrumentTypeChange: (v: string) => void;
|
||||
onDirectionChange: (v: string) => void;
|
||||
}
|
||||
|
||||
export function BasicInfoSection({
|
||||
formData, updateField, isViewMode, clients, conditions, onInstrumentTypeChange, onDirectionChange,
|
||||
}: BasicInfoSectionProps) {
|
||||
const {
|
||||
isReceived, isIssued, isCheck, isBill, canBeElectronic, isElectronic,
|
||||
receivedStatusOptions, issuedStatusOptions, paymentPlaceOptions,
|
||||
} = conditions;
|
||||
|
||||
const endorsementOrderOptions = useMemo(
|
||||
() => isElectronic ? ENDORSEMENT_ORDER_ELECTRONIC : [...ENDORSEMENT_ORDER_PAPER],
|
||||
[isElectronic]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* 어음번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>어음번호 <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.billNumber} onChange={(e) => updateField('billNumber', e.target.value)} placeholder="자동생성 또는 직접입력" disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 증권종류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>증권종류 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.instrumentType} onValueChange={onInstrumentTypeChange} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{INSTRUMENT_TYPE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래방향 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래방향 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.direction} onValueChange={onDirectionChange} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DIRECTION_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 전자/지류 */}
|
||||
<div className="space-y-2">
|
||||
<Label>전자/지류 <span className="text-red-500">*</span>
|
||||
{!canBeElectronic && <span className="text-xs text-muted-foreground ml-1">(전자어음법: 약속어음만 전자 가능)</span>}
|
||||
</Label>
|
||||
<Select value={formData.medium} onValueChange={(v) => updateField('medium', v as 'electronic' | 'paper')} disabled={isViewMode || !canBeElectronic}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MEDIUM_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래처 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{isReceived ? '거래처 (발행인)' : '수취인 (거래처)'} <span className="text-red-500">*</span></Label>
|
||||
<Select
|
||||
value={isReceived ? formData.vendor : formData.payee}
|
||||
onValueChange={(v) => updateField(isReceived ? 'vendor' : 'payee', v)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{clients.map(c => <SelectItem key={c.id} value={c.id}>{c.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.amount} onChange={(v) => updateField('amount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 발행일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>발행일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.issueDate} onChange={(d) => updateField('issueDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 만기일 (수표는 일람출급이므로 없음) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>만기일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.maturityDate} onChange={(d) => updateField('maturityDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 은행 */}
|
||||
<div className="space-y-2">
|
||||
<Label>{isReceived ? '발행은행' : '결제은행'}</Label>
|
||||
<Input
|
||||
value={isReceived ? formData.issuerBank : formData.settlementBank}
|
||||
onChange={(e) => updateField(isReceived ? 'issuerBank' : 'settlementBank', e.target.value)}
|
||||
placeholder={isReceived ? '예: 국민은행' : '예: 신한은행'}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 지급장소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>지급장소 <span className="text-red-500">*</span>
|
||||
{isCheck && <span className="text-xs text-muted-foreground ml-1">(수표: 은행만)</span>}
|
||||
</Label>
|
||||
<Select value={formData.paymentPlace} onValueChange={(v) => updateField('paymentPlace', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{paymentPlaceOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 지급장소 상세 */}
|
||||
{formData.paymentPlace === 'other' && (
|
||||
<div className="space-y-2">
|
||||
<Label>지급장소 상세</Label>
|
||||
<Input value={formData.paymentPlaceDetail} onChange={(e) => updateField('paymentPlaceDetail', e.target.value)} placeholder="지급장소를 직접 입력" disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 어음구분 (어음만) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>어음구분</Label>
|
||||
<Select value={formData.billCategory} onValueChange={(v) => updateField('billCategory', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_CATEGORY_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 받을어음 전용 필드 ===== */}
|
||||
{isReceived && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 여부</Label>
|
||||
<Select value={formData.endorsement} onValueChange={(v) => updateField('endorsement', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ENDORSEMENT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>배서차수</Label>
|
||||
<Select value={formData.endorsementOrder} onValueChange={(v) => updateField('endorsementOrder', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{endorsementOrderOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>보관장소</Label>
|
||||
<Select value={formData.storagePlace} onValueChange={(v) => updateField('storagePlace', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{STORAGE_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>결제상태 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.receivedStatus} onValueChange={(v) => updateField('receivedStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{receivedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 할인여부 (수표 제외) */}
|
||||
{isBill && (
|
||||
<div className="space-y-2">
|
||||
<Label>할인여부</Label>
|
||||
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
|
||||
<Switch checked={formData.isDiscounted} onCheckedChange={(c) => {
|
||||
updateField('isDiscounted', c);
|
||||
if (c) updateField('receivedStatus', 'discounted');
|
||||
}} disabled={isViewMode} />
|
||||
<span className="text-sm">{formData.isDiscounted ? '할인 적용' : '미적용'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ===== 지급어음 전용 필드 ===== */}
|
||||
{isIssued && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>결제방법</Label>
|
||||
<Select value={formData.paymentMethod} onValueChange={(v) => updateField('paymentMethod', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{PAYMENT_METHOD_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>지급상태 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.issuedStatus} onValueChange={(v) => updateField('issuedStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{issuedStatusOptions.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>실제결제일</Label>
|
||||
<DatePicker value={formData.actualPaymentDate} onChange={(d) => updateField('actualPaymentDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 입출금 계좌 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입금/출금 계좌</Label>
|
||||
<Input value={formData.bankAccountInfo} onChange={(e) => updateField('bankAccountInfo', e.target.value)} placeholder="계좌 정보" disabled={isViewMode} />
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2 lg:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Input value={formData.note} onChange={(e) => updateField('note', e.target.value)} placeholder="비고를 입력해주세요" disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function BuybackSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-orange-200 bg-orange-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-orange-700">환매 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-4">할인한 어음이 부도나 금융기관이 할인 의뢰인에게 어음금액을 청구(환매)한 경우</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>환매일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.buybackDate} onChange={(d) => updateField('buybackDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>환매금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.buybackAmount} onChange={(v) => updateField('buybackAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>환매요청 은행</Label>
|
||||
<Input value={formData.buybackBank} onChange={(e) => updateField('buybackBank', e.target.value)} placeholder="환매 청구 금융기관" disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { COLLECTION_RESULT_OPTIONS } from '../constants';
|
||||
|
||||
export function CollectionSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">추심 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 추심 의뢰 */}
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">추심 의뢰</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심은행 <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.collectionBank} onChange={(e) => updateField('collectionBank', e.target.value)} placeholder="추심 의뢰 은행" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심의뢰일 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.collectionRequestDate} onChange={(d) => updateField('collectionRequestDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심수수료</Label>
|
||||
<CurrencyInput value={formData.collectionFee} onChange={(v) => updateField('collectionFee', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
{/* 추심 결과 */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4">추심 결과</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>추심결과</Label>
|
||||
<Select value={formData.collectionResult} onValueChange={(v) => updateField('collectionResult', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{COLLECTION_RESULT_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심완료일</Label>
|
||||
<DatePicker value={formData.collectionCompleteDate} onChange={(d) => updateField('collectionCompleteDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심입금일</Label>
|
||||
<DatePicker value={formData.collectionDepositDate} onChange={(d) => updateField('collectionDepositDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>추심입금액 (수수료 차감후)</Label>
|
||||
<CurrencyInput value={formData.collectionDepositAmount} onChange={(v) => updateField('collectionDepositAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function DiscountInfoSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
const calcNetReceived = useMemo(() => {
|
||||
if (formData.amount > 0 && formData.discountAmount > 0) return formData.amount - formData.discountAmount;
|
||||
return 0;
|
||||
}, [formData.amount, formData.discountAmount]);
|
||||
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">할인 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>할인일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.discountDate} onChange={(d) => updateField('discountDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인처 (은행) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.discountBank} onChange={(e) => updateField('discountBank', e.target.value)} placeholder="예: 국민은행 강남지점" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인율 (%)</Label>
|
||||
<Input type="number" step="0.01" min={0} max={100} value={formData.discountRate || ''} onChange={(e) => {
|
||||
const rate = parseFloat(e.target.value) || 0;
|
||||
updateField('discountRate', rate);
|
||||
if (formData.amount > 0 && rate > 0) updateField('discountAmount', Math.round(formData.amount * rate / 100));
|
||||
}} placeholder="예: 3.5" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>할인금액</Label>
|
||||
<CurrencyInput value={formData.discountAmount} onChange={(v) => updateField('discountAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>실수령액 (자동계산)</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm font-semibold">
|
||||
{calcNetReceived > 0
|
||||
? <span className="text-green-700">₩ {calcNetReceived.toLocaleString()}</span>
|
||||
: <span className="text-gray-400">어음금액 - 할인금액</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { DISHONOR_REASON_OPTIONS } from '../constants';
|
||||
|
||||
export function DishonoredSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-red-200 bg-red-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-red-700">
|
||||
부도 정보
|
||||
<Badge variant="destructive" className="text-xs">부도</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>부도일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.dishonoredDate} onChange={(d) => {
|
||||
updateField('dishonoredDate', d);
|
||||
if (d) {
|
||||
const dt = new Date(d);
|
||||
dt.setDate(dt.getDate() + 6);
|
||||
updateField('recourseNoticeDeadline', dt.toISOString().split('T')[0]);
|
||||
}
|
||||
}} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>부도사유 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.dishonoredReason} onValueChange={(v) => updateField('dishonoredReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{DISHONOR_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{/* 법적 프로세스 */}
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide mb-4">법적 프로세스 (어음법 제44조·제45조)</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>거절증서 작성</Label>
|
||||
<div className="h-10 flex items-center gap-3 px-3 border rounded-md bg-gray-50">
|
||||
<Switch checked={formData.hasProtest} onCheckedChange={(c) => updateField('hasProtest', c)} disabled={isViewMode} />
|
||||
<span className="text-sm">{formData.hasProtest ? '작성 완료' : '미작성'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{formData.hasProtest && (
|
||||
<div className="space-y-2">
|
||||
<Label>거절증서 작성일</Label>
|
||||
<DatePicker value={formData.protestDate} onChange={(d) => updateField('protestDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>소구 통지일</Label>
|
||||
<DatePicker value={formData.recourseNoticeDate} onChange={(d) => updateField('recourseNoticeDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>통지 기한 (자동: 부도일+4영업일)</Label>
|
||||
<div className="h-10 flex items-center px-3 border rounded-md bg-gray-50 text-sm">
|
||||
{formData.recourseNoticeDeadline ? (
|
||||
<span className={
|
||||
formData.recourseNoticeDate && formData.recourseNoticeDate <= formData.recourseNoticeDeadline
|
||||
? 'text-green-700' : 'text-red-600 font-medium'
|
||||
}>
|
||||
{formData.recourseNoticeDeadline}
|
||||
{formData.recourseNoticeDate && formData.recourseNoticeDate > formData.recourseNoticeDeadline && ' (기한 초과!)'}
|
||||
</span>
|
||||
) : <span className="text-gray-400">부도일자 입력 시 자동계산</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function ElectronicBillSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">전자어음 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>전자어음 관리번호</Label>
|
||||
<Input value={formData.electronicBillNo} onChange={(e) => updateField('electronicBillNo', e.target.value)} placeholder="전자어음시스템 발급번호" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>등록기관</Label>
|
||||
<Select value={formData.registrationOrg} onValueChange={(v) => updateField('registrationOrg', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="kftc">금융결제원</SelectItem>
|
||||
<SelectItem value="bank">거래은행</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
|
||||
export function EndorsementSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">배서양도 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>배서일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.endorsementDate} onChange={(d) => updateField('endorsementDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>피배서인 (양수인) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.endorsee} onChange={(e) => updateField('endorsee', e.target.value)} placeholder="어음을 넘겨받는 자" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>배서 사유</Label>
|
||||
<Select value={formData.endorsementReason} onValueChange={(v) => updateField('endorsementReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="payment">대금결제</SelectItem>
|
||||
<SelectItem value="guarantee">담보제공</SelectItem>
|
||||
<SelectItem value="collection">추심위임</SelectItem>
|
||||
<SelectItem value="other">기타</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client';
|
||||
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { ACCEPTANCE_REFUSAL_REASON_OPTIONS } from '../constants';
|
||||
|
||||
interface ExchangeBillSectionProps extends SectionProps {
|
||||
showAcceptanceRefusal: boolean;
|
||||
}
|
||||
|
||||
export function ExchangeBillSection({ formData, updateField, isViewMode, showAcceptanceRefusal }: ExchangeBillSectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-purple-200">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">환어음 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>지급인 (Drawee) <span className="text-red-500">*</span></Label>
|
||||
<Input value={formData.drawee} onChange={(e) => updateField('drawee', e.target.value)} placeholder="지급 의무자" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>인수 여부</Label>
|
||||
<Select value={formData.acceptanceStatus} onValueChange={(v) => updateField('acceptanceStatus', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="accepted">인수 완료</SelectItem>
|
||||
<SelectItem value="pending">인수 대기</SelectItem>
|
||||
<SelectItem value="refused">인수 거절</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{formData.acceptanceStatus === 'refused' ? '인수거절일' : '인수일자'}</Label>
|
||||
<DatePicker
|
||||
value={formData.acceptanceStatus === 'refused' ? formData.acceptanceRefusalDate : formData.acceptanceDate}
|
||||
onChange={(d) => updateField(formData.acceptanceStatus === 'refused' ? 'acceptanceRefusalDate' : 'acceptanceDate', d)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{showAcceptanceRefusal && (
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex items-center gap-2 text-xs text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>인수거절 시 만기 전 소구권 행사 가능 (어음법 제43조). 거절증서 작성이 필요할 수 있습니다.</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>인수거절 사유</Label>
|
||||
<Select value={formData.acceptanceRefusalReason} onValueChange={(v) => updateField('acceptanceRefusalReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCEPTANCE_REFUSAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Plus, Trash2, AlertTriangle } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { BillFormData } from '../types';
|
||||
import { HISTORY_TYPE_OPTIONS } from '../constants';
|
||||
|
||||
interface HistorySectionProps {
|
||||
formData: BillFormData;
|
||||
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
|
||||
isViewMode: boolean;
|
||||
isElectronic: boolean;
|
||||
maxSplitCount: number;
|
||||
onAddInstallment: () => void;
|
||||
onRemoveInstallment: (id: string) => void;
|
||||
onUpdateInstallment: (id: string, field: string, value: string | number) => void;
|
||||
}
|
||||
|
||||
export function HistorySection({
|
||||
formData, updateField, isViewMode, isElectronic, maxSplitCount,
|
||||
onAddInstallment, onRemoveInstallment, onUpdateInstallment,
|
||||
}: HistorySectionProps) {
|
||||
const splitEndorsementStats = useMemo(() => {
|
||||
const splits = formData.installments.filter(inst => inst.type === 'splitEndorsement');
|
||||
const totalAmount = splits.reduce((sum, inst) => sum + inst.amount, 0);
|
||||
return { count: splits.length, totalAmount, remaining: formData.amount - totalAmount };
|
||||
}, [formData.installments, formData.amount]);
|
||||
|
||||
return (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">이력 관리</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button variant="outline" size="sm" onClick={onAddInstallment} className="text-orange-500 border-orange-300 hover:bg-orange-50">
|
||||
<Plus className="h-4 w-4 mr-1" />추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{/* 분할배서 토글 */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={formData.isSplit} onCheckedChange={(c) => updateField('isSplit', c)} disabled={isViewMode} />
|
||||
<Label>분할배서 허용</Label>
|
||||
{formData.isSplit && (
|
||||
<Badge variant="outline" className="text-xs text-amber-600 border-amber-300 bg-amber-50">
|
||||
최대 {maxSplitCount}회
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{formData.isSplit && isElectronic && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
|
||||
<AlertTriangle className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
<span>전자어음 분할배서: 최초 배서인에 한해 5회 미만 가능 (전자어음법 제6조)</span>
|
||||
</div>
|
||||
)}
|
||||
{formData.isSplit && splitEndorsementStats.count > 0 && (
|
||||
<div className="flex items-center gap-4 text-sm bg-gray-50 rounded-md px-3 py-2">
|
||||
<span className="text-muted-foreground">원금액:</span>
|
||||
<span className="font-semibold">₩ {formData.amount.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">| 분할배서 합계:</span>
|
||||
<span className="font-semibold text-blue-600">₩ {splitEndorsementStats.totalAmount.toLocaleString()}</span>
|
||||
<span className="text-muted-foreground">| 잔액:</span>
|
||||
<span className={`font-semibold ${splitEndorsementStats.remaining < 0 ? 'text-red-600' : 'text-green-600'}`}>
|
||||
₩ {splitEndorsementStats.remaining.toLocaleString()}
|
||||
</span>
|
||||
{splitEndorsementStats.remaining < 0 && (
|
||||
<span className="text-red-500 text-xs flex items-center gap-1"><AlertTriangle className="h-3 w-3" />금액 초과</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 이력 테이블 */}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px]">No</TableHead>
|
||||
<TableHead className="min-w-[130px]">일자</TableHead>
|
||||
<TableHead className="min-w-[130px]">처리구분</TableHead>
|
||||
<TableHead className="min-w-[120px]">금액</TableHead>
|
||||
<TableHead className="min-w-[120px]">상대처</TableHead>
|
||||
<TableHead className="min-w-[120px]">비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px]">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.installments.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 6 : 7} className="text-center text-gray-500 py-8">등록된 이력이 없습니다</TableCell>
|
||||
</TableRow>
|
||||
) : formData.installments.map((inst, idx) => (
|
||||
<TableRow key={inst.id} className={inst.type === 'splitEndorsement' ? 'bg-amber-50/50' : ''}>
|
||||
<TableCell className="text-center">{idx + 1}</TableCell>
|
||||
<TableCell>
|
||||
<DatePicker value={inst.date} onChange={(d) => onUpdateInstallment(inst.id, 'date', d)} size="sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select value={inst.type} onValueChange={(v) => onUpdateInstallment(inst.id, 'type', v)} disabled={isViewMode}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{HISTORY_TYPE_OPTIONS
|
||||
.filter(o => o.value !== 'splitEndorsement' || formData.isSplit)
|
||||
.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<CurrencyInput value={inst.amount} onChange={(v) => onUpdateInstallment(inst.id, 'amount', v ?? 0)} className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={inst.counterparty} onChange={(e) => onUpdateInstallment(inst.id, 'counterparty', e.target.value)} placeholder="거래처/은행" className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input value={inst.note} onChange={(e) => onUpdateInstallment(inst.id, 'note', e.target.value)} className="h-8 text-sm" disabled={isViewMode} />
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" onClick={() => onRemoveInstallment(inst.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { RECOURSE_REASON_OPTIONS } from '../constants';
|
||||
|
||||
export function RecourseSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-orange-200 bg-orange-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg text-orange-700">소구 (상환) 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-muted-foreground mb-4">배서양도한 어음이 부도나 피배서인이 소구권을 행사하여 상환을 요구한 경우</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>소구일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.recourseDate} onChange={(d) => updateField('recourseDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구금액 <span className="text-red-500">*</span></Label>
|
||||
<CurrencyInput value={formData.recourseAmount} onChange={(v) => updateField('recourseAmount', v ?? 0)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구대상 (청구인)</Label>
|
||||
<Input value={formData.recourseTarget} onChange={(e) => updateField('recourseTarget', e.target.value)} placeholder="피배서인(양수인)명" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>소구사유</Label>
|
||||
<Select value={formData.recourseReason} onValueChange={(v) => updateField('recourseReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECOURSE_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { SectionProps } from './types';
|
||||
import { RENEWAL_REASON_OPTIONS } from '../constants';
|
||||
|
||||
export function RenewalSection({ formData, updateField, isViewMode }: SectionProps) {
|
||||
return (
|
||||
<Card className="mb-6 border-amber-200 bg-amber-50/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2 text-amber-700">
|
||||
개서 정보
|
||||
<Badge variant="outline" className="text-xs border-amber-400 text-amber-700 bg-amber-50">만기연장</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>개서일자 <span className="text-red-500">*</span></Label>
|
||||
<DatePicker value={formData.renewalDate} onChange={(d) => updateField('renewalDate', d)} disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>신어음번호</Label>
|
||||
<Input value={formData.renewalNewBillNo} onChange={(e) => updateField('renewalNewBillNo', e.target.value)} placeholder="교체 발행된 신어음 번호" disabled={isViewMode} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>개서 사유 <span className="text-red-500">*</span></Label>
|
||||
<Select value={formData.renewalReason} onValueChange={(v) => updateField('renewalReason', v)} disabled={isViewMode}>
|
||||
<SelectTrigger><SelectValue placeholder="사유 선택" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{RENEWAL_REASON_OPTIONS.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
11
src/components/accounting/BillManagement/sections/index.ts
Normal file
11
src/components/accounting/BillManagement/sections/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { BasicInfoSection } from './BasicInfoSection';
|
||||
export { ElectronicBillSection } from './ElectronicBillSection';
|
||||
export { ExchangeBillSection } from './ExchangeBillSection';
|
||||
export { DiscountInfoSection } from './DiscountInfoSection';
|
||||
export { EndorsementSection } from './EndorsementSection';
|
||||
export { CollectionSection } from './CollectionSection';
|
||||
export { HistorySection } from './HistorySection';
|
||||
export { RenewalSection } from './RenewalSection';
|
||||
export { RecourseSection } from './RecourseSection';
|
||||
export { BuybackSection } from './BuybackSection';
|
||||
export { DishonoredSection } from './DishonoredSection';
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { BillFormData } from '../types';
|
||||
|
||||
export interface SectionProps {
|
||||
formData: BillFormData;
|
||||
updateField: <K extends keyof BillFormData>(field: K, value: BillFormData[K]) => void;
|
||||
isViewMode: boolean;
|
||||
}
|
||||
@@ -174,8 +174,10 @@ export function getBillStatusOptions(billType: BillType) {
|
||||
export interface BillApiInstallment {
|
||||
id: number;
|
||||
bill_id: number;
|
||||
type?: string;
|
||||
installment_date: string;
|
||||
amount: string;
|
||||
counterparty?: string | null;
|
||||
note: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -190,7 +192,7 @@ export interface BillApiData {
|
||||
client_name: string | null;
|
||||
amount: string;
|
||||
issue_date: string;
|
||||
maturity_date: string;
|
||||
maturity_date: string | null;
|
||||
status: BillStatus;
|
||||
reason: string | null;
|
||||
installment_count: number;
|
||||
@@ -211,6 +213,58 @@ export interface BillApiData {
|
||||
account_name: string;
|
||||
} | null;
|
||||
installments?: BillApiInstallment[];
|
||||
// V8 확장 필드
|
||||
instrument_type?: string;
|
||||
medium?: string;
|
||||
bill_category?: string;
|
||||
electronic_bill_no?: string | null;
|
||||
registration_org?: string | null;
|
||||
drawee?: string | null;
|
||||
acceptance_status?: string | null;
|
||||
acceptance_date?: string | null;
|
||||
acceptance_refusal_date?: string | null;
|
||||
acceptance_refusal_reason?: string | null;
|
||||
endorsement?: string | null;
|
||||
endorsement_order?: string | null;
|
||||
storage_place?: string | null;
|
||||
issuer_bank?: string | null;
|
||||
is_discounted?: boolean;
|
||||
discount_date?: string | null;
|
||||
discount_bank?: string | null;
|
||||
discount_rate?: string | null;
|
||||
discount_amount?: string | null;
|
||||
endorsement_date?: string | null;
|
||||
endorsee?: string | null;
|
||||
endorsement_reason?: string | null;
|
||||
collection_bank?: string | null;
|
||||
collection_request_date?: string | null;
|
||||
collection_fee?: string | null;
|
||||
collection_complete_date?: string | null;
|
||||
collection_result?: string | null;
|
||||
collection_deposit_date?: string | null;
|
||||
collection_deposit_amount?: string | null;
|
||||
settlement_bank?: string | null;
|
||||
payment_method?: string | null;
|
||||
actual_payment_date?: string | null;
|
||||
payment_place?: string | null;
|
||||
payment_place_detail?: string | null;
|
||||
renewal_date?: string | null;
|
||||
renewal_new_bill_no?: string | null;
|
||||
renewal_reason?: string | null;
|
||||
recourse_date?: string | null;
|
||||
recourse_amount?: string | null;
|
||||
recourse_target?: string | null;
|
||||
recourse_reason?: string | null;
|
||||
buyback_date?: string | null;
|
||||
buyback_amount?: string | null;
|
||||
buyback_bank?: string | null;
|
||||
dishonored_date?: string | null;
|
||||
dishonored_reason?: string | null;
|
||||
has_protest?: boolean;
|
||||
protest_date?: string | null;
|
||||
recourse_notice_date?: string | null;
|
||||
recourse_notice_deadline?: string | null;
|
||||
is_split?: boolean;
|
||||
}
|
||||
|
||||
export interface BillApiResponse {
|
||||
@@ -235,7 +289,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
|
||||
vendorName: apiData.client?.name || apiData.client_name || '',
|
||||
amount: parseFloat(apiData.amount),
|
||||
issueDate: apiData.issue_date,
|
||||
maturityDate: apiData.maturity_date,
|
||||
maturityDate: apiData.maturity_date || '',
|
||||
status: apiData.status,
|
||||
reason: apiData.reason || '',
|
||||
installmentCount: apiData.installment_count,
|
||||
@@ -251,7 +305,7 @@ export function transformApiToFrontend(apiData: BillApiData): BillRecord {
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 함수 =====
|
||||
// ===== Frontend → API 변환 함수 (V8 전체 필드 전송) =====
|
||||
export function transformFrontendToApi(data: Partial<BillRecord>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
@@ -261,7 +315,7 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
|
||||
if (data.vendorName !== undefined) result.client_name = data.vendorName || null;
|
||||
if (data.amount !== undefined) result.amount = data.amount;
|
||||
if (data.issueDate !== undefined) result.issue_date = data.issueDate;
|
||||
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate;
|
||||
if (data.maturityDate !== undefined) result.maturity_date = data.maturityDate || null;
|
||||
if (data.status !== undefined) result.status = data.status;
|
||||
if (data.reason !== undefined) result.reason = data.reason || null;
|
||||
if (data.note !== undefined) result.note = data.note || null;
|
||||
@@ -275,4 +329,334 @@ export function transformFrontendToApi(data: Partial<BillRecord>): Record<string
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== BillFormData → API payload 변환 (V8 전체 필드 전송) =====
|
||||
export function transformFormDataToApi(data: BillFormData, vendorName: string): Record<string, unknown> {
|
||||
const isReceived = data.direction === 'received';
|
||||
const orNull = (v: string) => v || null;
|
||||
const orNullNum = (v: number) => v || null;
|
||||
const orNullDate = (v: string) => v || null;
|
||||
|
||||
return {
|
||||
// 기존 12개 필드
|
||||
bill_number: data.billNumber,
|
||||
bill_type: data.direction,
|
||||
client_id: isReceived ? (data.vendor ? parseInt(data.vendor) : null) : (data.payee ? parseInt(data.payee) : null),
|
||||
client_name: vendorName || null,
|
||||
amount: data.amount,
|
||||
issue_date: data.issueDate,
|
||||
maturity_date: orNullDate(data.maturityDate),
|
||||
status: isReceived ? data.receivedStatus : data.issuedStatus,
|
||||
note: orNull(data.note),
|
||||
is_electronic: data.medium === 'electronic',
|
||||
// V8 확장 필드
|
||||
instrument_type: data.instrumentType,
|
||||
medium: data.medium,
|
||||
bill_category: orNull(data.billCategory),
|
||||
electronic_bill_no: orNull(data.electronicBillNo),
|
||||
registration_org: orNull(data.registrationOrg),
|
||||
drawee: orNull(data.drawee),
|
||||
acceptance_status: orNull(data.acceptanceStatus),
|
||||
acceptance_date: orNullDate(data.acceptanceDate),
|
||||
acceptance_refusal_date: orNullDate(data.acceptanceRefusalDate),
|
||||
acceptance_refusal_reason: orNull(data.acceptanceRefusalReason),
|
||||
endorsement: orNull(data.endorsement),
|
||||
endorsement_order: orNull(data.endorsementOrder),
|
||||
storage_place: orNull(data.storagePlace),
|
||||
issuer_bank: orNull(data.issuerBank),
|
||||
is_discounted: data.isDiscounted,
|
||||
discount_date: orNullDate(data.discountDate),
|
||||
discount_bank: orNull(data.discountBank),
|
||||
discount_rate: orNullNum(data.discountRate),
|
||||
discount_amount: orNullNum(data.discountAmount),
|
||||
endorsement_date: orNullDate(data.endorsementDate),
|
||||
endorsee: orNull(data.endorsee),
|
||||
endorsement_reason: orNull(data.endorsementReason),
|
||||
collection_bank: orNull(data.collectionBank),
|
||||
collection_request_date: orNullDate(data.collectionRequestDate),
|
||||
collection_fee: orNullNum(data.collectionFee),
|
||||
collection_complete_date: orNullDate(data.collectionCompleteDate),
|
||||
collection_result: orNull(data.collectionResult),
|
||||
collection_deposit_date: orNullDate(data.collectionDepositDate),
|
||||
collection_deposit_amount: orNullNum(data.collectionDepositAmount),
|
||||
settlement_bank: orNull(data.settlementBank),
|
||||
payment_method: orNull(data.paymentMethod),
|
||||
actual_payment_date: orNullDate(data.actualPaymentDate),
|
||||
payment_place: orNull(data.paymentPlace),
|
||||
payment_place_detail: orNull(data.paymentPlaceDetail),
|
||||
renewal_date: orNullDate(data.renewalDate),
|
||||
renewal_new_bill_no: orNull(data.renewalNewBillNo),
|
||||
renewal_reason: orNull(data.renewalReason),
|
||||
recourse_date: orNullDate(data.recourseDate),
|
||||
recourse_amount: orNullNum(data.recourseAmount),
|
||||
recourse_target: orNull(data.recourseTarget),
|
||||
recourse_reason: orNull(data.recourseReason),
|
||||
buyback_date: orNullDate(data.buybackDate),
|
||||
buyback_amount: orNullNum(data.buybackAmount),
|
||||
buyback_bank: orNull(data.buybackBank),
|
||||
dishonored_date: orNullDate(data.dishonoredDate),
|
||||
dishonored_reason: orNull(data.dishonoredReason),
|
||||
has_protest: data.hasProtest,
|
||||
protest_date: orNullDate(data.protestDate),
|
||||
recourse_notice_date: orNullDate(data.recourseNoticeDate),
|
||||
recourse_notice_deadline: orNullDate(data.recourseNoticeDeadline),
|
||||
is_split: data.isSplit,
|
||||
// 이력(차수)
|
||||
installments: data.installments.map(inst => ({
|
||||
date: inst.date,
|
||||
type: inst.type || 'other',
|
||||
amount: inst.amount,
|
||||
counterparty: orNull(inst.counterparty),
|
||||
note: orNull(inst.note),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// V8 확장 타입 (프로토타입 → 실제 페이지 마이그레이션)
|
||||
// =============================================
|
||||
|
||||
// ===== 증권종류 =====
|
||||
export type InstrumentType = 'promissory' | 'exchange' | 'cashierCheck' | 'currentCheck';
|
||||
|
||||
// ===== 거래방향 (Direction = BillType alias) =====
|
||||
export type Direction = 'received' | 'issued';
|
||||
|
||||
// ===== 매체 =====
|
||||
export type Medium = 'electronic' | 'paper';
|
||||
|
||||
// ===== 이력 레코드 (V8: 처리구분/상대처 추가) =====
|
||||
export interface HistoryRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
type: string; // 처리구분 (HISTORY_TYPE_OPTIONS)
|
||||
amount: number;
|
||||
counterparty: string; // 상대처
|
||||
note: string;
|
||||
}
|
||||
|
||||
// ===== V8 폼 데이터 (전체 ~45개 필드) =====
|
||||
export interface BillFormData {
|
||||
// === 공통 ===
|
||||
billNumber: string;
|
||||
instrumentType: InstrumentType;
|
||||
direction: Direction;
|
||||
medium: Medium;
|
||||
amount: number;
|
||||
issueDate: string;
|
||||
maturityDate: string;
|
||||
note: string;
|
||||
// === 전자어음 (조건: medium=electronic) ===
|
||||
electronicBillNo: string;
|
||||
registrationOrg: string;
|
||||
// === 환어음 (조건: instrumentType=exchange) ===
|
||||
drawee: string;
|
||||
acceptanceStatus: string;
|
||||
acceptanceDate: string;
|
||||
// === 받을어음 전용 ===
|
||||
vendor: string;
|
||||
billCategory: string;
|
||||
issuerBank: string;
|
||||
endorsement: string;
|
||||
endorsementOrder: string;
|
||||
storagePlace: string;
|
||||
receivedStatus: string;
|
||||
isDiscounted: boolean;
|
||||
discountDate: string;
|
||||
discountBank: string;
|
||||
discountRate: number;
|
||||
discountAmount: number;
|
||||
// 배서양도
|
||||
endorsementDate: string;
|
||||
endorsee: string;
|
||||
endorsementReason: string;
|
||||
// 추심
|
||||
collectionBank: string;
|
||||
collectionRequestDate: string;
|
||||
collectionFee: number;
|
||||
collectionCompleteDate: string;
|
||||
collectionResult: string;
|
||||
collectionDepositDate: string;
|
||||
collectionDepositAmount: number;
|
||||
// === 지급어음 전용 ===
|
||||
payee: string;
|
||||
settlementBank: string;
|
||||
paymentMethod: string;
|
||||
issuedStatus: string;
|
||||
actualPaymentDate: string;
|
||||
// === 공통 ===
|
||||
paymentPlace: string;
|
||||
paymentPlaceDetail: string;
|
||||
// === 개서 ===
|
||||
renewalDate: string;
|
||||
renewalNewBillNo: string;
|
||||
renewalReason: string;
|
||||
// === 소구/환매 ===
|
||||
recourseDate: string;
|
||||
recourseAmount: number;
|
||||
recourseTarget: string;
|
||||
recourseReason: string;
|
||||
buybackDate: string;
|
||||
buybackAmount: number;
|
||||
buybackBank: string;
|
||||
// === 환어음 인수거절 ===
|
||||
acceptanceRefusalDate: string;
|
||||
acceptanceRefusalReason: string;
|
||||
// === 공통 조건부 ===
|
||||
isSplit: boolean;
|
||||
splitCount: number;
|
||||
splitAmount: number;
|
||||
dishonoredDate: string;
|
||||
dishonoredReason: string;
|
||||
// 부도 법적 프로세스
|
||||
hasProtest: boolean;
|
||||
protestDate: string;
|
||||
recourseNoticeDate: string;
|
||||
recourseNoticeDeadline: string;
|
||||
// === 이력 관리 ===
|
||||
installments: HistoryRecord[];
|
||||
// === 입출금 계좌 ===
|
||||
bankAccountInfo: string;
|
||||
}
|
||||
|
||||
// ===== 초기 폼 데이터 =====
|
||||
export const INITIAL_BILL_FORM_DATA: BillFormData = {
|
||||
billNumber: '', instrumentType: 'promissory', direction: 'received',
|
||||
medium: 'paper', amount: 0, issueDate: '', maturityDate: '', note: '',
|
||||
electronicBillNo: '', registrationOrg: '',
|
||||
drawee: '', acceptanceStatus: '', acceptanceDate: '',
|
||||
vendor: '', billCategory: 'commercial', issuerBank: '', endorsement: 'endorsable', endorsementOrder: '1',
|
||||
storagePlace: '', receivedStatus: 'stored', isDiscounted: false,
|
||||
discountDate: '', discountBank: '', discountRate: 0, discountAmount: 0,
|
||||
endorsementDate: '', endorsee: '', endorsementReason: '',
|
||||
collectionBank: '', collectionRequestDate: '', collectionFee: 0,
|
||||
collectionCompleteDate: '', collectionResult: '', collectionDepositDate: '', collectionDepositAmount: 0,
|
||||
payee: '', settlementBank: '', paymentMethod: 'autoTransfer',
|
||||
issuedStatus: 'stored', actualPaymentDate: '',
|
||||
paymentPlace: '', paymentPlaceDetail: '',
|
||||
renewalDate: '', renewalNewBillNo: '', renewalReason: '',
|
||||
recourseDate: '', recourseAmount: 0, recourseTarget: '', recourseReason: '',
|
||||
buybackDate: '', buybackAmount: 0, buybackBank: '',
|
||||
acceptanceRefusalDate: '', acceptanceRefusalReason: '',
|
||||
isSplit: false, splitCount: 0, splitAmount: 0,
|
||||
dishonoredDate: '', dishonoredReason: '',
|
||||
hasProtest: false, protestDate: '', recourseNoticeDate: '', recourseNoticeDeadline: '',
|
||||
installments: [], bankAccountInfo: '',
|
||||
};
|
||||
|
||||
// ===== BillApiData → BillFormData 직접 변환 (V8 전체 필드 매핑) =====
|
||||
export function apiDataToFormData(apiData: BillApiData): BillFormData {
|
||||
const pf = (v: string | null | undefined) => v ? parseFloat(v) : 0;
|
||||
|
||||
return {
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
billNumber: apiData.bill_number,
|
||||
instrumentType: (apiData.instrument_type as InstrumentType) || 'promissory',
|
||||
direction: apiData.bill_type as Direction,
|
||||
medium: (apiData.medium as Medium) || (apiData.is_electronic ? 'electronic' : 'paper'),
|
||||
amount: parseFloat(apiData.amount),
|
||||
issueDate: apiData.issue_date,
|
||||
maturityDate: apiData.maturity_date || '',
|
||||
note: apiData.note || '',
|
||||
// 전자어음
|
||||
electronicBillNo: apiData.electronic_bill_no || '',
|
||||
registrationOrg: apiData.registration_org || '',
|
||||
// 환어음
|
||||
drawee: apiData.drawee || '',
|
||||
acceptanceStatus: apiData.acceptance_status || '',
|
||||
acceptanceDate: apiData.acceptance_date || '',
|
||||
acceptanceRefusalDate: apiData.acceptance_refusal_date || '',
|
||||
acceptanceRefusalReason: apiData.acceptance_refusal_reason || '',
|
||||
// 거래처
|
||||
vendor: apiData.bill_type === 'received' && apiData.client_id ? String(apiData.client_id) : '',
|
||||
payee: apiData.bill_type === 'issued' && apiData.client_id ? String(apiData.client_id) : '',
|
||||
// 받을어음 전용
|
||||
billCategory: apiData.bill_category || 'commercial',
|
||||
issuerBank: apiData.issuer_bank || '',
|
||||
endorsement: apiData.endorsement || 'endorsable',
|
||||
endorsementOrder: apiData.endorsement_order || '1',
|
||||
storagePlace: apiData.storage_place || '',
|
||||
receivedStatus: apiData.bill_type === 'received' ? apiData.status : 'stored',
|
||||
isDiscounted: apiData.is_discounted ?? false,
|
||||
discountDate: apiData.discount_date || '',
|
||||
discountBank: apiData.discount_bank || '',
|
||||
discountRate: pf(apiData.discount_rate),
|
||||
discountAmount: pf(apiData.discount_amount),
|
||||
endorsementDate: apiData.endorsement_date || '',
|
||||
endorsee: apiData.endorsee || '',
|
||||
endorsementReason: apiData.endorsement_reason || '',
|
||||
collectionBank: apiData.collection_bank || '',
|
||||
collectionRequestDate: apiData.collection_request_date || '',
|
||||
collectionFee: pf(apiData.collection_fee),
|
||||
collectionCompleteDate: apiData.collection_complete_date || '',
|
||||
collectionResult: apiData.collection_result || '',
|
||||
collectionDepositDate: apiData.collection_deposit_date || '',
|
||||
collectionDepositAmount: pf(apiData.collection_deposit_amount),
|
||||
// 지급어음 전용
|
||||
settlementBank: apiData.settlement_bank || '',
|
||||
paymentMethod: apiData.payment_method || 'autoTransfer',
|
||||
issuedStatus: apiData.bill_type === 'issued' ? apiData.status : 'stored',
|
||||
actualPaymentDate: apiData.actual_payment_date || '',
|
||||
// 공통
|
||||
paymentPlace: apiData.payment_place || '',
|
||||
paymentPlaceDetail: apiData.payment_place_detail || '',
|
||||
// 개서
|
||||
renewalDate: apiData.renewal_date || '',
|
||||
renewalNewBillNo: apiData.renewal_new_bill_no || '',
|
||||
renewalReason: apiData.renewal_reason || '',
|
||||
// 소구/환매
|
||||
recourseDate: apiData.recourse_date || '',
|
||||
recourseAmount: pf(apiData.recourse_amount),
|
||||
recourseTarget: apiData.recourse_target || '',
|
||||
recourseReason: apiData.recourse_reason || '',
|
||||
buybackDate: apiData.buyback_date || '',
|
||||
buybackAmount: pf(apiData.buyback_amount),
|
||||
buybackBank: apiData.buyback_bank || '',
|
||||
// 부도
|
||||
isSplit: apiData.is_split ?? false,
|
||||
splitCount: 0,
|
||||
splitAmount: 0,
|
||||
dishonoredDate: apiData.dishonored_date || '',
|
||||
dishonoredReason: apiData.dishonored_reason || '',
|
||||
hasProtest: apiData.has_protest ?? false,
|
||||
protestDate: apiData.protest_date || '',
|
||||
recourseNoticeDate: apiData.recourse_notice_date || '',
|
||||
recourseNoticeDeadline: apiData.recourse_notice_deadline || '',
|
||||
// 이력
|
||||
installments: (apiData.installments || []).map(inst => ({
|
||||
id: String(inst.id),
|
||||
date: inst.installment_date,
|
||||
type: inst.type || 'other',
|
||||
amount: parseFloat(inst.amount),
|
||||
counterparty: inst.counterparty || '',
|
||||
note: inst.note || '',
|
||||
})),
|
||||
bankAccountInfo: apiData.bank_account_id ? String(apiData.bank_account_id) : '',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== BillRecord → BillFormData 변환 (하위호환 유지) =====
|
||||
export function billRecordToFormData(record: BillRecord): BillFormData {
|
||||
return {
|
||||
...INITIAL_BILL_FORM_DATA,
|
||||
billNumber: record.billNumber,
|
||||
direction: record.billType as Direction,
|
||||
amount: record.amount,
|
||||
issueDate: record.issueDate,
|
||||
maturityDate: record.maturityDate,
|
||||
note: record.note,
|
||||
receivedStatus: record.billType === 'received' ? record.status : 'stored',
|
||||
issuedStatus: record.billType === 'issued' ? record.status : 'stored',
|
||||
vendor: record.billType === 'received' ? record.vendorId : '',
|
||||
payee: record.billType === 'issued' ? record.vendorId : '',
|
||||
installments: record.installments.map(inst => ({
|
||||
id: inst.id,
|
||||
date: inst.date,
|
||||
type: 'other',
|
||||
amount: inst.amount,
|
||||
counterparty: '',
|
||||
note: inst.note,
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CardTransaction, JournalEntryItem } from './types';
|
||||
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
|
||||
import { DEDUCTION_OPTIONS } from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { saveJournalEntries } from './actions';
|
||||
|
||||
interface JournalEntryModalProps {
|
||||
@@ -194,23 +195,16 @@ export function JournalEntryModal({ open, onOpenChange, transaction, onSuccess }
|
||||
|
||||
{/* 계정과목 + 공제 + 증빙/판매자상호 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* Select - FormField 예외 */}
|
||||
<div>
|
||||
<Label className="text-xs">계정과목</Label>
|
||||
<Select
|
||||
value={item.accountSubject || 'none'}
|
||||
onValueChange={(v) => updateItem(index, 'accountSubject', v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-1">
|
||||
<AccountSubjectSelect
|
||||
value={item.accountSubject}
|
||||
onValueChange={(v) => updateItem(index, 'accountSubject', v)}
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Select - FormField 예외 */}
|
||||
<div>
|
||||
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import type { ManualInputFormData } from './types';
|
||||
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
|
||||
import { DEDUCTION_OPTIONS } from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { getCardList, createCardTransaction } from './actions';
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
|
||||
@@ -254,20 +255,13 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">계정과목</Label>
|
||||
<Select
|
||||
value={formData.accountSubject || 'none'}
|
||||
onValueChange={(v) => handleChange('accountSubject', v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-1">
|
||||
<AccountSubjectSelect
|
||||
value={formData.accountSubject}
|
||||
onValueChange={(v) => handleChange('accountSubject', v)}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -42,6 +42,7 @@ import type { CardTransaction, InlineEditData, SortOption } from './types';
|
||||
import {
|
||||
SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import {
|
||||
getCardTransactionList,
|
||||
getCardTransactionSummary,
|
||||
@@ -55,23 +56,46 @@ 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]' },
|
||||
{ key: 'cardCompany', label: '카드사', className: 'min-w-[80px]' },
|
||||
{ key: 'card', label: '카드번호', className: 'min-w-[100px]' },
|
||||
{ key: 'cardName', label: '카드명', className: 'min-w-[80px]' },
|
||||
{ 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: 'deductionType', label: '공제', className: 'min-w-[95px]', sortable: false },
|
||||
{ key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]' },
|
||||
{ key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]' },
|
||||
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[130px]', sortable: false },
|
||||
{ key: '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: '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: 'journalEntry', label: '분개', className: 'w-16 text-center', sortable: false },
|
||||
{ key: 'hide', label: '숨김', className: 'w-16 text-center', sortable: false },
|
||||
];
|
||||
@@ -269,9 +293,45 @@ export function CardTransactionInquiry() {
|
||||
setShowJournalEntry(true);
|
||||
}, []);
|
||||
|
||||
const handleExcelDownload = useCallback(() => {
|
||||
toast.info('엑셀 다운로드 기능은 백엔드 연동 후 활성화됩니다.');
|
||||
}, []);
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
toast.info('엑셀 파일 생성 중...');
|
||||
const allData: CardTransaction[] = [];
|
||||
let page = 1;
|
||||
let lastPage = 1;
|
||||
|
||||
do {
|
||||
const result = await getCardTransactionList({
|
||||
startDate,
|
||||
endDate,
|
||||
search: searchQuery || undefined,
|
||||
perPage: 100,
|
||||
page,
|
||||
});
|
||||
if (result.success && result.data.length > 0) {
|
||||
allData.push(...result.data);
|
||||
lastPage = result.pagination?.lastPage ?? 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
page++;
|
||||
} while (page <= lastPage);
|
||||
|
||||
if (allData.length > 0) {
|
||||
await downloadExcel<CardTransaction & Record<string, unknown>>({
|
||||
data: allData as (CardTransaction & Record<string, unknown>)[],
|
||||
columns: excelColumns as ExcelColumn<CardTransaction & Record<string, unknown>>[],
|
||||
filename: '카드사용내역',
|
||||
sheetName: '카드사용내역',
|
||||
});
|
||||
toast.success('엑셀 다운로드 완료');
|
||||
} else {
|
||||
toast.warning('다운로드할 데이터가 없습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, [startDate, endDate, searchQuery]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<CardTransaction> = useMemo(
|
||||
@@ -540,20 +600,13 @@ export function CardTransactionInquiry() {
|
||||
</TableCell>
|
||||
{/* 계정과목 (인라인 Select) */}
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Select
|
||||
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || 'none'}
|
||||
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs min-w-[90px] w-auto">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<AccountSubjectSelect
|
||||
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || ''}
|
||||
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v)}
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
className="min-w-[90px] w-auto"
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 분개 버튼 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
'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';
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { format, parseISO, subMonths, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { Download, FileText, Loader2, RefreshCw, Calendar } from 'lucide-react';
|
||||
import { Download, FileText, Loader2, Printer, Search } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -15,18 +15,28 @@ import {
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from '@/components/ui/table';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { DateRangePicker } from '@/components/ui/date-range-picker';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { printElement } from '@/lib/print-utils';
|
||||
import type { NoteReceivableItem, DailyAccountItem } from './types';
|
||||
import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types';
|
||||
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
|
||||
// ===== 빠른 월 선택 버튼 정의 =====
|
||||
const QUICK_MONTH_BUTTONS = [
|
||||
{ label: '이번달', months: 0 },
|
||||
{ label: '지난달', months: 1 },
|
||||
{ label: 'D-2월', months: 2 },
|
||||
{ label: 'D-3월', months: 3 },
|
||||
{ label: 'D-4월', months: 4 },
|
||||
{ label: 'D-5월', months: 5 },
|
||||
] as const;
|
||||
|
||||
// ===== Props 인터페이스 =====
|
||||
interface DailyReportProps {
|
||||
initialNoteReceivables?: NoteReceivableItem[];
|
||||
@@ -36,7 +46,9 @@ interface DailyReportProps {
|
||||
export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts = [] }: DailyReportProps) {
|
||||
const { canExport } = usePermission();
|
||||
// ===== 상태 관리 =====
|
||||
const [selectedDate, setSelectedDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
|
||||
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(() => format(new Date(), 'yyyy-MM-dd'));
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [noteReceivables, setNoteReceivables] = useState<NoteReceivableItem[]>(initialNoteReceivables);
|
||||
const [dailyAccounts, setDailyAccounts] = useState<DailyAccountItem[]>(initialDailyAccounts);
|
||||
const [summary, setSummary] = useState<{
|
||||
@@ -53,9 +65,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [noteResult, accountResult, summaryResult] = await Promise.all([
|
||||
getNoteReceivables({ date: selectedDate }),
|
||||
getDailyAccounts({ date: selectedDate }),
|
||||
getDailyReportSummary({ date: selectedDate }),
|
||||
getNoteReceivables({ date: startDate }),
|
||||
getDailyAccounts({ date: startDate }),
|
||||
getDailyReportSummary({ date: startDate }),
|
||||
]);
|
||||
|
||||
if (noteResult.success) {
|
||||
@@ -81,20 +93,20 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [startDate]);
|
||||
|
||||
// ===== 초기 로드 및 날짜 변경시 재로드 =====
|
||||
const isInitialMount = useRef(true);
|
||||
const prevDateRef = useRef(selectedDate);
|
||||
const prevDateRef = useRef(startDate);
|
||||
|
||||
useEffect(() => {
|
||||
// 초기 마운트 또는 날짜가 실제로 변경된 경우에만 로드
|
||||
if (isInitialMount.current || prevDateRef.current !== selectedDate) {
|
||||
if (isInitialMount.current || prevDateRef.current !== startDate) {
|
||||
isInitialMount.current = false;
|
||||
prevDateRef.current = selectedDate;
|
||||
prevDateRef.current = startDate;
|
||||
loadData();
|
||||
}
|
||||
}, [selectedDate, loadData]);
|
||||
}, [startDate, loadData]);
|
||||
|
||||
// ===== 어음 합계 (API 요약 사용) =====
|
||||
const noteReceivableTotal = useMemo(() => {
|
||||
@@ -144,9 +156,9 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
}, [accountTotals]);
|
||||
|
||||
// ===== 선택된 날짜 정보 =====
|
||||
const selectedDateInfo = useMemo(() => {
|
||||
const startDateInfo = useMemo(() => {
|
||||
try {
|
||||
const date = parseISO(selectedDate);
|
||||
const date = parseISO(startDate);
|
||||
return {
|
||||
formatted: format(date, 'yyyy년 M월 d일', { locale: ko }),
|
||||
dayOfWeek: format(date, 'EEEE', { locale: ko }),
|
||||
@@ -154,12 +166,12 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
} catch {
|
||||
return { formatted: '', dayOfWeek: '' };
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [startDate]);
|
||||
|
||||
// ===== 엑셀 다운로드 (프록시 API 직접 호출) =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
const url = `/api/proxy/daily-report/export?date=${selectedDate}`;
|
||||
const url = `/api/proxy/daily-report/export?date=${startDate}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -169,7 +181,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
|
||||
const blob = await response.blob();
|
||||
const contentDisposition = response.headers.get('Content-Disposition');
|
||||
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${selectedDate}.xlsx`;
|
||||
const filename = contentDisposition?.match(/filename="?(.+)"?/)?.[1] || `일일일보_${startDate}.xlsx`;
|
||||
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
@@ -183,7 +195,55 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
||||
}
|
||||
}, [selectedDate]);
|
||||
}, [startDate]);
|
||||
|
||||
// ===== 빠른 월 선택 =====
|
||||
const handleQuickMonth = useCallback((monthsAgo: number) => {
|
||||
const target = monthsAgo === 0 ? new Date() : subMonths(new Date(), monthsAgo);
|
||||
setStartDate(format(startOfMonth(target), 'yyyy-MM-dd'));
|
||||
setEndDate(format(endOfMonth(target), 'yyyy-MM-dd'));
|
||||
}, []);
|
||||
|
||||
// ===== 인쇄 =====
|
||||
const printAreaRef = useRef<HTMLDivElement>(null);
|
||||
const handlePrint = useCallback(() => {
|
||||
if (printAreaRef.current) {
|
||||
printElement(printAreaRef.current, {
|
||||
title: `일일일보_${startDate}`,
|
||||
styles: `
|
||||
.print-container { font-size: 11px; }
|
||||
table { width: 100%; margin-bottom: 12px; }
|
||||
h3 { margin-bottom: 8px; }
|
||||
`,
|
||||
});
|
||||
}
|
||||
}, [startDate]);
|
||||
|
||||
// ===== USD 금액 포맷 =====
|
||||
const formatUsd = useCallback((value: number) => `$ ${formatAmount(value)}`, []);
|
||||
|
||||
// ===== 검색 필터링 =====
|
||||
const filteredNoteReceivables = useMemo(() => {
|
||||
if (!searchTerm) return noteReceivables;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return noteReceivables.filter(item =>
|
||||
item.content.toLowerCase().includes(term)
|
||||
);
|
||||
}, [noteReceivables, searchTerm]);
|
||||
|
||||
const filteredDailyAccounts = useMemo(() => {
|
||||
if (!searchTerm) return dailyAccounts;
|
||||
const term = searchTerm.toLowerCase();
|
||||
return dailyAccounts.filter(item =>
|
||||
item.category.toLowerCase().includes(term)
|
||||
);
|
||||
}, [dailyAccounts, searchTerm]);
|
||||
|
||||
// ===== USD 데이터 존재 여부 =====
|
||||
const hasUsdAccounts = useMemo(() =>
|
||||
filteredDailyAccounts.some(item => item.currency === 'USD'),
|
||||
[filteredDailyAccounts]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
@@ -194,62 +254,81 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
{/* 헤더 액션 (날짜 선택, 버튼 등) */}
|
||||
{/* 헤더 액션 (날짜 선택, 빠른 월 선택, 검색, 인쇄) */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Calendar className="h-4 w-4 text-gray-500 shrink-0" />
|
||||
<span className="text-sm font-medium text-gray-700 shrink-0">조회 일자</span>
|
||||
<DatePicker
|
||||
value={selectedDate}
|
||||
onChange={setSelectedDate}
|
||||
className="w-auto min-w-[140px]"
|
||||
size="sm"
|
||||
align="start"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadData}
|
||||
disabled={isLoading}
|
||||
className="h-8 px-2 text-xs"
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="mr-1 h-3.5 w-3.5" />
|
||||
)}
|
||||
새로고침
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-8 px-2 text-xs">
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀
|
||||
<CardContent className="p-3 md:p-4">
|
||||
<div className="flex flex-col gap-2 md:gap-3">
|
||||
{/* DateRange */}
|
||||
<DateRangePicker
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
size="sm"
|
||||
className="w-full md:w-auto md:min-w-[280px]"
|
||||
displayFormat="yyyy-MM-dd"
|
||||
/>
|
||||
{/* 빠른 월 선택 버튼 - 모바일: 가로 스크롤 */}
|
||||
<div className="flex items-center gap-1.5 md:gap-2 overflow-x-auto pb-1 -mb-1">
|
||||
{QUICK_MONTH_BUTTONS.map((btn) => (
|
||||
<Button
|
||||
key={btn.label}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 md:h-8 px-2 md:px-2.5 text-xs shrink-0"
|
||||
onClick={() => handleQuickMonth(btn.months)}
|
||||
>
|
||||
{btn.label}
|
||||
</Button>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
{/* 검색 + 인쇄/엑셀 - 모바일: 세로 배치 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:justify-between">
|
||||
<div className="relative flex-1 sm:max-w-[300px]">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="검색..."
|
||||
className="pl-8 h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint} className="h-7 md:h-8 px-2 md:px-3 text-xs">
|
||||
<Printer className="mr-1 h-3.5 w-3.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
{canExport && (
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload} className="h-7 md:h-8 px-2 md:px-3 text-xs">
|
||||
<Download className="mr-1 h-3.5 w-3.5" />
|
||||
엑셀
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 어음 및 외상매출채권현황 */}
|
||||
{/* 인쇄 영역 */}
|
||||
<div ref={printAreaRef} className="print-area space-y-4 md:space-y-6">
|
||||
{/* 일자별 입출금 합계 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">어음 및 외상매출채권현황</h3>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||||
<h3 className="text-base md:text-lg font-semibold">
|
||||
일자: {startDateInfo.formatted} {startDateInfo.dayOfWeek}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="min-w-[550px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<div className="rounded-md border overflow-x-auto max-h-[40vh] md:max-h-[50vh] overflow-y-auto">
|
||||
<div className="min-w-[420px] md:min-w-[650px]">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold max-w-[200px]">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">만기일</TableHead>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">구분</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">입금</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">출금</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">잔액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -258,129 +337,343 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500">데이터를 불러오는 중...</span>
|
||||
<span className="text-gray-500 text-sm">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : noteReceivables.length === 0 ? (
|
||||
) : filteredDailyAccounts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
noteReceivables.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[200px] truncate">{item.content}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">{item.dueDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
{noteReceivables.length > 0 && (
|
||||
<TableFooter>
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 일자별 상세 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold">
|
||||
일자: {selectedDateInfo.formatted} {selectedDateInfo.dayOfWeek}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto">
|
||||
<div className="min-w-[650px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold max-w-[180px]">구분</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap">상태</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">전월 이월</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">수입</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">지출</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap">잔액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : dailyAccounts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{/* KRW 계좌들 */}
|
||||
{dailyAccounts
|
||||
{filteredDailyAccounts
|
||||
.filter(item => item.currency === 'KRW')
|
||||
.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[180px] truncate">{item.category}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap">
|
||||
<Badge className={MATCH_STATUS_COLORS[item.matchStatus]}>
|
||||
{MATCH_STATUS_LABELS[item.matchStatus]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.carryover)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">{formatAmount(item.balance)}</TableCell>
|
||||
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.balance)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* KRW 소계 */}
|
||||
{hasUsdAccounts && (
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell className="text-xs md:text-sm font-semibold">원화(KRW) 소계</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatAmount(accountTotals.krw.balance)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{/* USD 계좌들 */}
|
||||
{hasUsdAccounts && filteredDailyAccounts
|
||||
.filter(item => item.currency === 'USD')
|
||||
.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[140px] md:max-w-[180px] truncate text-xs md:text-sm">{item.category}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.balance)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* USD 소계 */}
|
||||
{hasUsdAccounts && (
|
||||
<TableRow className="bg-muted/30 font-medium">
|
||||
<TableCell className="text-xs md:text-sm font-semibold">외국환(USD) 소계</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm font-semibold">{formatUsd(accountTotals.usd.balance)}</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
{dailyAccounts.length > 0 && (
|
||||
{filteredDailyAccounts.length > 0 && (
|
||||
<TableFooter>
|
||||
{/* 외화원 (USD) 합계 */}
|
||||
<TableRow className="bg-blue-50/50">
|
||||
<TableCell className="font-semibold whitespace-nowrap">외화원 (USD) 합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.carryover)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.income)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.expense)}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">${formatAmount(accountTotals.usd.balance)}</TableCell>
|
||||
</TableRow>
|
||||
{/* 현금성 자산 합계 */}
|
||||
{/* 합계 */}
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold whitespace-nowrap">현금성 자산 합계</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.carryover)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap">{formatAmount(cashAssetTotal.balance)}</TableCell>
|
||||
<TableCell className="font-bold whitespace-nowrap text-xs md:text-sm">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.balance)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</Table>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 예금 입출금 내역 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-center mb-3 md:mb-4 py-1.5 md:py-2 bg-gray-100 rounded-md">
|
||||
<h3 className="text-base md:text-lg font-semibold">예금 입출금 내역</h3>
|
||||
</div>
|
||||
{/* KRW 입출금 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||
{/* KRW 입금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-blue-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-blue-700 text-sm md:text-base">입금 (KRW)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">입금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
입금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'KRW' && item.income > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`deposit-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.income)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-blue-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">입금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.income)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KRW 출금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-red-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-red-700 text-sm md:text-base">출금 (KRW)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">출금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-gray-400 mx-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
출금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'KRW' && item.expense > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`withdrawal-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.expense)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-red-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">출금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(cashAssetTotal.expense)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* USD 입출금 — USD 데이터가 있을 때만 표시 */}
|
||||
{hasUsdAccounts && (
|
||||
<>
|
||||
<div className="flex items-center justify-center mt-4 mb-3 py-1.5 md:py-2 bg-emerald-50 rounded-md">
|
||||
<h3 className="text-base md:text-lg font-semibold text-emerald-800">외국환(USD) 입출금 내역</h3>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 md:gap-4">
|
||||
{/* USD 입금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-emerald-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-emerald-700 text-sm md:text-base">입금 (USD)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">입금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.income > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
USD 입금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'USD' && item.income > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`usd-deposit-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.income)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-emerald-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">입금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.income)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* USD 출금 */}
|
||||
<div>
|
||||
<div className="text-center py-1 md:py-1.5 bg-orange-50 rounded-t-md border border-b-0">
|
||||
<span className="font-semibold text-orange-700 text-sm md:text-base">출금 (USD)</span>
|
||||
</div>
|
||||
<div className="rounded-b-md border overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">출금처/적요</TableHead>
|
||||
<TableHead className="font-semibold text-right text-xs md:text-sm">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredDailyAccounts.filter(item => item.currency === 'USD' && item.expense > 0).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center py-4 md:py-6 text-muted-foreground text-xs md:text-sm">
|
||||
USD 출금 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredDailyAccounts
|
||||
.filter(item => item.currency === 'USD' && item.expense > 0)
|
||||
.map((item) => (
|
||||
<TableRow key={`usd-withdrawal-${item.id}`} className="hover:bg-muted/50">
|
||||
<TableCell>
|
||||
<div className="text-xs md:text-sm">{item.category}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatUsd(item.expense)}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
<TableFooter>
|
||||
<TableRow className="bg-orange-50/50">
|
||||
<TableCell className="font-bold text-xs md:text-sm">출금 합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatUsd(accountTotals.usd.expense)}</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 어음 및 외상매출채권현황 */}
|
||||
<Card>
|
||||
<CardContent className="px-3 pt-4 pb-3 md:px-6 md:pt-6 md:pb-6">
|
||||
<div className="flex items-center justify-between mb-3 md:mb-4">
|
||||
<h3 className="text-base md:text-lg font-semibold">어음 및 외상매출채권현황</h3>
|
||||
</div>
|
||||
<div className="rounded-md border overflow-x-auto max-h-[25vh] md:max-h-[30vh] overflow-y-auto">
|
||||
<div className="min-w-[480px] md:min-w-[550px]">
|
||||
<table className="w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-10 bg-background shadow-[0_1px_0_0_hsl(var(--border))]">
|
||||
<TableRow>
|
||||
<TableHead className="font-semibold text-xs md:text-sm">내용</TableHead>
|
||||
<TableHead className="font-semibold text-right whitespace-nowrap text-xs md:text-sm">현재 잔액</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">발행일</TableHead>
|
||||
<TableHead className="font-semibold text-center whitespace-nowrap text-xs md:text-sm">만기일</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-gray-400" />
|
||||
<span className="text-gray-500 text-sm">데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredNoteReceivables.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground text-sm">
|
||||
데이터가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredNoteReceivables.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="max-w-[160px] md:max-w-[200px] truncate text-xs md:text-sm">{item.content}</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap text-xs md:text-sm">{formatAmount(item.currentBalance)}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.issueDate}</TableCell>
|
||||
<TableCell className="text-center whitespace-nowrap text-xs md:text-sm">{item.dueDate}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
{filteredNoteReceivables.length > 0 && (
|
||||
<TableFooter className="sticky bottom-0 z-10 bg-background">
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="font-bold text-xs md:text-sm">합계</TableCell>
|
||||
<TableCell className="text-right font-bold whitespace-nowrap text-xs md:text-sm">{formatAmount(noteReceivableTotal)}</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getBankAccounts,
|
||||
} from './actions';
|
||||
import { useDevFill, generateDepositData } from '@/components/dev';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
|
||||
// ===== Props =====
|
||||
interface DepositDetailClientV2Props {
|
||||
@@ -81,14 +82,17 @@ export default function DepositDetailClientV2({
|
||||
: await updateDeposit(depositId!, submitData as Partial<DepositRecord>);
|
||||
|
||||
if (result.success && mode === 'create') {
|
||||
invalidateDashboard('deposit');
|
||||
toast.success('등록되었습니다.');
|
||||
router.push('/ko/accounting/deposits');
|
||||
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
|
||||
}
|
||||
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error };
|
||||
if (result.success) {
|
||||
invalidateDashboard('deposit');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
},
|
||||
[mode, depositId, router]
|
||||
);
|
||||
@@ -98,9 +102,11 @@ export default function DepositDetailClientV2({
|
||||
if (!depositId) return { success: false, error: 'ID가 없습니다.' };
|
||||
|
||||
const result = await deleteDeposit(depositId);
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error };
|
||||
if (result.success) {
|
||||
invalidateDashboard('deposit');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
}, [depositId]);
|
||||
|
||||
// ===== 모드 변경 핸들러 =====
|
||||
|
||||
@@ -20,10 +20,8 @@ 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 {
|
||||
@@ -73,6 +71,7 @@ import { deleteDeposit, updateDepositTypes, getDeposits } from './actions';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
|
||||
import { toast } from 'sonner';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
extractUniqueOptions,
|
||||
@@ -83,13 +82,13 @@ import {
|
||||
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ 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 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// ===== 컴포넌트 Props =====
|
||||
@@ -103,7 +102,7 @@ interface DepositManagementProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function DepositManagement({ initialData, initialPagination }: DepositManagementProps) {
|
||||
export function DepositManagement({ initialData, initialPagination: _initialPagination }: DepositManagementProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
@@ -225,6 +224,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteDeposit(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('deposit');
|
||||
toast.success('입금 내역이 삭제되었습니다.');
|
||||
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
|
||||
await handleRefresh();
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function getClients(): Promise<{
|
||||
success: boolean; data: { id: string; name: string }[]; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
|
||||
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
|
||||
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
|
||||
type ClientApi = { id: number; name: string };
|
||||
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useState, useMemo, useCallback, useTransition, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import {
|
||||
Receipt,
|
||||
Calendar as CalendarIcon,
|
||||
@@ -58,7 +59,7 @@ import {
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { ListMobileCard } from '@/components/organisms/MobileCard';
|
||||
import type {
|
||||
ExpectedExpenseRecord,
|
||||
TransactionType,
|
||||
@@ -81,15 +82,14 @@ 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,6 +247,7 @@ export function ExpectedExpenseManagement({
|
||||
// 수정
|
||||
const result = await updateExpectedExpense(editingItem.id, formData);
|
||||
if (result.success && result.data) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.map(item => item.id === editingItem.id ? result.data! : item));
|
||||
toast.success('미지급비용이 수정되었습니다.');
|
||||
setShowFormDialog(false);
|
||||
@@ -258,6 +259,7 @@ export function ExpectedExpenseManagement({
|
||||
// 등록
|
||||
const result = await createExpectedExpense(formData);
|
||||
if (result.success && result.data) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => [result.data!, ...prev]);
|
||||
toast.success('미지급비용이 등록되었습니다.');
|
||||
setShowFormDialog(false);
|
||||
@@ -278,6 +280,7 @@ export function ExpectedExpenseManagement({
|
||||
startTransition(async () => {
|
||||
const result = await deleteExpectedExpenses(selectedIds);
|
||||
if (result.success) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${result.deletedCount || selectedIds.length}건이 삭제되었습니다.`);
|
||||
@@ -481,7 +484,7 @@ export function ExpectedExpenseManagement({
|
||||
// ===== 액션 핸들러 =====
|
||||
// 상세페이지 없음 - 행 클릭 시 이동하지 않음
|
||||
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
const _handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteTargetId(id);
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
@@ -492,6 +495,7 @@ export function ExpectedExpenseManagement({
|
||||
startTransition(async () => {
|
||||
const result = await deleteExpectedExpense(deleteTargetId);
|
||||
if (result.success) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.filter(item => item.id !== deleteTargetId));
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -522,6 +526,7 @@ export function ExpectedExpenseManagement({
|
||||
startTransition(async () => {
|
||||
const result = await updateExpectedPaymentDate(selectedIds, newExpectedDate);
|
||||
if (result.success) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.map(item =>
|
||||
selectedItems.has(item.id)
|
||||
? { ...item, expectedPaymentDate: newExpectedDate }
|
||||
@@ -555,11 +560,11 @@ export function ExpectedExpenseManagement({
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ 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: '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: 'approvalStatus', label: '전자결재', className: 'text-center', sortable: true },
|
||||
], []);
|
||||
|
||||
@@ -582,9 +587,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') {
|
||||
@@ -694,9 +699,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);
|
||||
@@ -1185,21 +1190,12 @@ export function ExpectedExpenseManagement({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>계정과목</Label>
|
||||
<Select
|
||||
value={formData.accountSubject}
|
||||
<AccountSubjectSelect
|
||||
value={formData.accountSubject || ''}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, accountSubject: value }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="계정과목 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
placeholder="계정과목 선택"
|
||||
category="expense"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -56,14 +57,12 @@ import {
|
||||
getJournalDetail,
|
||||
updateJournalDetail,
|
||||
deleteJournalDetail,
|
||||
getAccountSubjects,
|
||||
getVendorList,
|
||||
} from './actions';
|
||||
import type {
|
||||
GeneralJournalRecord,
|
||||
JournalEntryRow,
|
||||
JournalSide,
|
||||
AccountSubject,
|
||||
VendorOption,
|
||||
} from './types';
|
||||
import { JOURNAL_SIDE_OPTIONS, JOURNAL_DIVISION_LABELS } from './types';
|
||||
@@ -109,7 +108,6 @@ export function JournalEditModal({
|
||||
const [accountNumber, setAccountNumber] = useState('');
|
||||
|
||||
// 옵션 데이터
|
||||
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
|
||||
const [vendors, setVendors] = useState<VendorOption[]>([]);
|
||||
|
||||
// 데이터 로드
|
||||
@@ -119,15 +117,11 @@ export function JournalEditModal({
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [detailRes, subjectsRes, vendorsRes] = await Promise.all([
|
||||
const [detailRes, vendorsRes] = await Promise.all([
|
||||
getJournalDetail(record.id),
|
||||
getAccountSubjects({ category: 'all' }),
|
||||
getVendorList(),
|
||||
]);
|
||||
|
||||
if (subjectsRes.success && subjectsRes.data) {
|
||||
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
|
||||
}
|
||||
if (vendorsRes.success && vendorsRes.data) {
|
||||
setVendors(vendorsRes.data);
|
||||
}
|
||||
@@ -361,24 +355,14 @@ export function JournalEditModal({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={row.accountSubjectId || 'none'}
|
||||
<AccountSubjectSelect
|
||||
value={row.accountSubjectId}
|
||||
onValueChange={(v) =>
|
||||
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
|
||||
handleRowChange(row.id, 'accountSubjectId', v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{accountSubjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
size="sm"
|
||||
placeholder="선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
|
||||
@@ -42,8 +42,9 @@ import {
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from '@/components/ui/table';
|
||||
import { createManualJournal, getAccountSubjects, getVendorList } from './actions';
|
||||
import type { JournalEntryRow, JournalSide, AccountSubject, VendorOption } from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { createManualJournal, getVendorList } from './actions';
|
||||
import type { JournalEntryRow, VendorOption } from './types';
|
||||
import { JOURNAL_SIDE_OPTIONS } from './types';
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
|
||||
@@ -81,7 +82,6 @@ export function ManualJournalEntryModal({
|
||||
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
|
||||
|
||||
// 옵션 데이터
|
||||
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
|
||||
const [vendors, setVendors] = useState<VendorOption[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -94,13 +94,7 @@ export function ManualJournalEntryModal({
|
||||
setDescription('');
|
||||
setRows([createEmptyRow()]);
|
||||
|
||||
Promise.all([
|
||||
getAccountSubjects({ category: 'all' }),
|
||||
getVendorList(),
|
||||
]).then(([subjectsRes, vendorsRes]) => {
|
||||
if (subjectsRes.success && subjectsRes.data) {
|
||||
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
|
||||
}
|
||||
getVendorList().then((vendorsRes) => {
|
||||
if (vendorsRes.success && vendorsRes.data) {
|
||||
setVendors(vendorsRes.data);
|
||||
}
|
||||
@@ -272,24 +266,14 @@ export function ManualJournalEntryModal({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
value={row.accountSubjectId || 'none'}
|
||||
<AccountSubjectSelect
|
||||
value={row.accountSubjectId}
|
||||
onValueChange={(v) =>
|
||||
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
|
||||
handleRowChange(row.id, 'accountSubjectId', v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{accountSubjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
size="sm"
|
||||
placeholder="선택"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
|
||||
@@ -8,69 +8,14 @@ import type {
|
||||
GeneralJournalApiData,
|
||||
GeneralJournalSummary,
|
||||
GeneralJournalSummaryApiData,
|
||||
AccountSubject,
|
||||
AccountSubjectApiData,
|
||||
JournalEntryRow,
|
||||
VendorOption,
|
||||
} from './types';
|
||||
import {
|
||||
transformApiToFrontend,
|
||||
transformSummaryApi,
|
||||
transformAccountSubjectApi,
|
||||
} from './types';
|
||||
|
||||
// ===== Mock 데이터 (개발용) =====
|
||||
function generateMockJournalData(): GeneralJournalRecord[] {
|
||||
const descriptions = ['사무용품 구매', '직원 급여', '임대료 지급', '매출 입금', '교통비'];
|
||||
const journalDescs = ['복리후생비', '급여', '임차료', '매출', '여비교통비'];
|
||||
const divisions: Array<'deposit' | 'withdrawal' | 'transfer'> = ['deposit', 'withdrawal', 'transfer'];
|
||||
const sources: Array<'manual' | 'linked'> = ['manual', 'linked'];
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const division = divisions[i % 3];
|
||||
const depositAmount = division === 'deposit' ? 100000 * (i + 1) : 0;
|
||||
const withdrawalAmount = division === 'withdrawal' ? 80000 * (i + 1) : 0;
|
||||
return {
|
||||
id: String(5000 + i),
|
||||
date: '2025-12-12',
|
||||
division,
|
||||
amount: depositAmount || withdrawalAmount || 50000,
|
||||
description: descriptions[i % 5],
|
||||
journalDescription: journalDescs[i % 5],
|
||||
depositAmount,
|
||||
withdrawalAmount,
|
||||
balance: 1000000 - (i * 50000),
|
||||
debitAmount: [6000, 100000, 50000, 0, 30000][i % 5],
|
||||
creditAmount: [0, 0, 50000, 100000, 0][i % 5],
|
||||
source: sources[i % 4 === 0 ? 0 : 1],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generateMockSummary(): GeneralJournalSummary {
|
||||
return { totalCount: 10, depositCount: 4, depositAmount: 400000, withdrawalCount: 3, withdrawalAmount: 300000, journalCompleteCount: 7, journalIncompleteCount: 3 };
|
||||
}
|
||||
|
||||
function generateMockAccountSubjects(): AccountSubject[] {
|
||||
return [
|
||||
{ id: '101', code: '1010', name: '현금', category: 'asset', isActive: true },
|
||||
{ id: '102', code: '1020', name: '보통예금', category: 'asset', isActive: true },
|
||||
{ id: '201', code: '2010', name: '미지급금', category: 'liability', isActive: true },
|
||||
{ id: '401', code: '4010', name: '매출', category: 'revenue', isActive: true },
|
||||
{ id: '501', code: '5010', name: '복리후생비', category: 'expense', isActive: true },
|
||||
];
|
||||
}
|
||||
|
||||
function generateMockVendors(): VendorOption[] {
|
||||
return [
|
||||
{ id: '1', name: '삼성전자' },
|
||||
{ id: '2', name: '(주)한국물류' },
|
||||
{ id: '3', name: 'LG전자' },
|
||||
{ id: '4', name: '현대모비스' },
|
||||
{ id: '5', name: '(주)대한상사' },
|
||||
];
|
||||
}
|
||||
|
||||
// ===== 전표 목록 조회 =====
|
||||
export async function getJournalEntries(params: {
|
||||
startDate?: string;
|
||||
@@ -91,15 +36,6 @@ export async function getJournalEntries(params: {
|
||||
errorMessage: '전표 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || result.data.length === 0) {
|
||||
const mockData = generateMockJournalData();
|
||||
return {
|
||||
success: true as const,
|
||||
data: mockData,
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockData.length },
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -119,10 +55,6 @@ export async function getJournalSummary(params: {
|
||||
errorMessage: '전표 요약 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data) {
|
||||
return { success: true, data: generateMockSummary() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -151,67 +83,6 @@ export async function createManualJournal(data: {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 목록 조회 =====
|
||||
export async function getAccountSubjects(params?: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
}): Promise<ActionResult<AccountSubject[]>> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/account-subjects', {
|
||||
search: params?.search || undefined,
|
||||
category: params?.category && params.category !== 'all' ? params.category : undefined,
|
||||
}),
|
||||
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
|
||||
errorMessage: '계정과목 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data || result.data.length === 0) {
|
||||
return { success: true, data: generateMockAccountSubjects() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 계정과목 추가 =====
|
||||
export async function createAccountSubject(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/account-subjects'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
},
|
||||
errorMessage: '계정과목 추가에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 상태 토글 =====
|
||||
export async function updateAccountSubjectStatus(
|
||||
id: string,
|
||||
isActive: boolean
|
||||
): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
|
||||
method: 'PATCH',
|
||||
body: { is_active: isActive },
|
||||
errorMessage: '계정과목 상태 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 삭제 =====
|
||||
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '계정과목 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 분개 상세 조회 =====
|
||||
type JournalDetailData = {
|
||||
id: number;
|
||||
@@ -241,26 +112,6 @@ export async function getJournalDetail(id: string): Promise<ActionResult<Journal
|
||||
errorMessage: '분개 상세 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: Number(id),
|
||||
date: '2025-12-12',
|
||||
division: 'deposit',
|
||||
amount: 100000,
|
||||
description: '사무용품 구매',
|
||||
bank_name: '신한은행',
|
||||
account_number: '110-123-456789',
|
||||
journal_memo: '',
|
||||
rows: [
|
||||
{ id: 1, side: 'debit', account_subject_id: 501, account_subject_name: '복리후생비', vendor_id: 1, vendor_name: '삼성전자', debit_amount: 100000, credit_amount: 0, memo: '' },
|
||||
{ id: 2, side: 'credit', account_subject_id: 101, account_subject_name: '현금', vendor_id: null, vendor_name: '', debit_amount: 0, credit_amount: 100000, memo: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -308,9 +159,5 @@ export async function getVendorList(): Promise<ActionResult<VendorOption[]>> {
|
||||
errorMessage: '거래처 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data || result.data.length === 0) {
|
||||
return { success: true, data: generateMockVendors() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
|
||||
import { getJournalEntries, getJournalSummary } from './actions';
|
||||
import { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
|
||||
import { AccountSubjectSettingModal } from '@/components/accounting/common';
|
||||
import { ManualJournalEntryModal } from './ManualJournalEntryModal';
|
||||
import { JournalEditModal } from './JournalEditModal';
|
||||
import type { GeneralJournalRecord, GeneralJournalSummary, PeriodButtonValue } from './types';
|
||||
@@ -38,18 +38,19 @@ 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]' },
|
||||
{ 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: '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: 'journalAction', label: '분개', className: 'text-center w-[70px]', sortable: false },
|
||||
];
|
||||
|
||||
@@ -151,12 +152,14 @@ export function GeneralJournalEntry() {
|
||||
const handleManualEntrySuccess = useCallback(() => {
|
||||
setShowManualEntry(false);
|
||||
loadData();
|
||||
invalidateDashboard('journalEntry');
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 분개 수정 완료 =====
|
||||
const handleJournalEditSuccess = useCallback(() => {
|
||||
setJournalEditTarget(null);
|
||||
loadData();
|
||||
invalidateDashboard('journalEntry');
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 합계 계산 =====
|
||||
|
||||
@@ -34,30 +34,6 @@ export const PERIOD_BUTTONS = [
|
||||
|
||||
export type PeriodButtonValue = (typeof PERIOD_BUTTONS)[number]['value'];
|
||||
|
||||
// ===== 계정과목 분류 =====
|
||||
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
|
||||
|
||||
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
|
||||
{ value: 'asset', label: '자산' },
|
||||
{ value: 'liability', label: '부채' },
|
||||
{ value: 'capital', label: '자본' },
|
||||
{ value: 'revenue', label: '수익' },
|
||||
{ value: 'expense', label: '비용' },
|
||||
];
|
||||
|
||||
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
...ACCOUNT_CATEGORY_OPTIONS,
|
||||
];
|
||||
|
||||
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
|
||||
asset: '자산',
|
||||
liability: '부채',
|
||||
capital: '자본',
|
||||
revenue: '수익',
|
||||
expense: '비용',
|
||||
};
|
||||
|
||||
// ===== 분개 구분 (차변/대변) =====
|
||||
export type JournalSide = 'debit' | 'credit';
|
||||
|
||||
@@ -121,25 +97,6 @@ export interface GeneralJournalSummaryApiData {
|
||||
journal_incomplete_count?: number;
|
||||
}
|
||||
|
||||
// ===== 계정과목 =====
|
||||
export interface AccountSubject {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category: AccountSubjectCategory;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface AccountSubjectApiData {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
is_active: boolean | number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ===== 분개 행 =====
|
||||
export interface JournalEntryRow {
|
||||
id: string;
|
||||
@@ -216,17 +173,6 @@ export function transformSummaryApi(apiData: GeneralJournalSummaryApiData): Gene
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 계정과목 API → Frontend 변환 =====
|
||||
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
code: apiData.code,
|
||||
name: apiData.name,
|
||||
category: apiData.category as AccountSubjectCategory,
|
||||
isActive: Boolean(apiData.is_active),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 기간 버튼 → 날짜 변환 =====
|
||||
export function getPeriodDates(period: PeriodButtonValue): { start: string; end: string } {
|
||||
const today = new Date();
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
updateGiftCertificate,
|
||||
deleteGiftCertificate,
|
||||
} from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import {
|
||||
PURCHASE_PURPOSE_OPTIONS,
|
||||
ENTERTAINMENT_EXPENSE_OPTIONS,
|
||||
@@ -80,6 +81,7 @@ export function GiftCertificateDetail({
|
||||
: await updateGiftCertificate(id!, formData);
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('giftCertificate');
|
||||
toast.success(isNew ? '상품권이 등록되었습니다.' : '상품권이 수정되었습니다.');
|
||||
router.push('/ko/accounting/gift-certificates');
|
||||
} else {
|
||||
@@ -96,6 +98,7 @@ export function GiftCertificateDetail({
|
||||
try {
|
||||
const result = await deleteGiftCertificate(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('giftCertificate');
|
||||
toast.success('상품권이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/gift-certificates');
|
||||
} else {
|
||||
@@ -134,8 +137,8 @@ export function GiftCertificateDetail({
|
||||
label="일련번호"
|
||||
value={formData.serialNumber}
|
||||
onChange={(v) => handleChange('serialNumber', v)}
|
||||
placeholder="자동 생성"
|
||||
disabled={!isNew}
|
||||
placeholder="일련번호를 입력하세요"
|
||||
disabled={!isEditable}
|
||||
/>
|
||||
<FormField
|
||||
label="상품권명"
|
||||
|
||||
@@ -1,144 +1,106 @@
|
||||
/**
|
||||
* 상품권 관리 서버 액션 (Mock)
|
||||
* 상품권 관리 서버 액션
|
||||
*
|
||||
* API Endpoints (예정):
|
||||
* - GET /api/v1/gift-certificates - 목록 조회
|
||||
* - GET /api/v1/gift-certificates/{id} - 상세 조회
|
||||
* - POST /api/v1/gift-certificates - 등록
|
||||
* - PUT /api/v1/gift-certificates/{id} - 수정
|
||||
* - DELETE /api/v1/gift-certificates/{id} - 삭제
|
||||
* - GET /api/v1/gift-certificates/summary - 요약 통계
|
||||
* API Endpoints (Loan API 재사용, category='gift_certificate'):
|
||||
* - GET /api/v1/loans?category=gift_certificate - 목록 조회
|
||||
* - GET /api/v1/loans/{id} - 상세 조회
|
||||
* - POST /api/v1/loans - 등록
|
||||
* - PUT /api/v1/loans/{id} - 수정
|
||||
* - DELETE /api/v1/loans/{id} - 삭제
|
||||
* - GET /api/v1/loans/summary?category=gift_certificate - 요약 통계
|
||||
*/
|
||||
|
||||
'use server';
|
||||
|
||||
import type { ActionResult } from '@/lib/api/execute-server-action';
|
||||
// import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
// import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
// import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { executePaginatedAction, type PaginatedActionResult } from '@/lib/api/execute-paginated-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type {
|
||||
GiftCertificateRecord,
|
||||
GiftCertificateFormData,
|
||||
LoanApiData,
|
||||
} from './types';
|
||||
import {
|
||||
transformApiToRecord,
|
||||
transformApiToFormData,
|
||||
transformFormToApi,
|
||||
} from './types';
|
||||
|
||||
// ===== 상품권 목록 조회 (Mock) =====
|
||||
export async function getGiftCertificates(_params?: {
|
||||
// ===== 상품권 목록 조회 =====
|
||||
export async function getGiftCertificates(params?: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
status?: string;
|
||||
}): Promise<ActionResult<GiftCertificateRecord[]>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executePaginatedAction<GiftCertificateApiData, GiftCertificateRecord>({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates', { ... }),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 목록 조회에 실패했습니다.',
|
||||
// });
|
||||
return { success: true, data: [] };
|
||||
search?: string;
|
||||
}): Promise<PaginatedActionResult<GiftCertificateRecord>> {
|
||||
return executePaginatedAction<LoanApiData, GiftCertificateRecord>({
|
||||
url: buildApiUrl('/api/v1/loans', {
|
||||
category: 'gift_certificate',
|
||||
page: params?.page,
|
||||
per_page: params?.perPage,
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
status: params?.status && params.status !== 'all' ? params.status : undefined,
|
||||
search: params?.search,
|
||||
}),
|
||||
transform: transformApiToRecord,
|
||||
errorMessage: '상품권 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 상세 조회 (Mock) =====
|
||||
// ===== 상품권 상세 조회 =====
|
||||
export async function getGiftCertificateById(
|
||||
_id: string
|
||||
id: string
|
||||
): Promise<ActionResult<GiftCertificateFormData>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||
// transform: transformDetailApiToFrontend,
|
||||
// errorMessage: '상품권 조회에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
serialNumber: 'GC-2026-001',
|
||||
name: '신세계 상품권',
|
||||
faceValue: 500000,
|
||||
vendorId: '',
|
||||
vendorName: '신세계백화점',
|
||||
purchaseDate: '2026-02-10',
|
||||
purchasePurpose: 'entertainment',
|
||||
entertainmentExpense: 'applicable',
|
||||
status: 'used',
|
||||
usedDate: '2026-02-20',
|
||||
recipientName: '홍길동',
|
||||
recipientOrganization: '(주)테크솔루션',
|
||||
usageDescription: '거래처 접대용',
|
||||
memo: '2월 접대비 처리 완료',
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/loans/${id}`),
|
||||
transform: (data: LoanApiData) => transformApiToFormData(data),
|
||||
errorMessage: '상품권 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 등록 (Mock) =====
|
||||
// ===== 상품권 등록 =====
|
||||
export async function createGiftCertificate(
|
||||
_data: GiftCertificateFormData
|
||||
data: GiftCertificateFormData
|
||||
): Promise<ActionResult<GiftCertificateRecord>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates'),
|
||||
// method: 'POST',
|
||||
// body: transformFrontendToApi(data),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 등록에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: crypto.randomUUID(),
|
||||
serialNumber: _data.serialNumber || `GC-${Date.now()}`,
|
||||
name: _data.name,
|
||||
faceValue: _data.faceValue,
|
||||
purchaseDate: _data.purchaseDate,
|
||||
usedDate: _data.usedDate || null,
|
||||
status: _data.status,
|
||||
entertainmentExpense: _data.entertainmentExpense,
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/loans'),
|
||||
method: 'POST',
|
||||
body: transformFormToApi(data),
|
||||
transform: (d: LoanApiData) => transformApiToRecord(d),
|
||||
errorMessage: '상품권 등록에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 수정 (Mock) =====
|
||||
// ===== 상품권 수정 =====
|
||||
export async function updateGiftCertificate(
|
||||
_id: string,
|
||||
_data: GiftCertificateFormData
|
||||
id: string,
|
||||
data: GiftCertificateFormData
|
||||
): Promise<ActionResult<GiftCertificateRecord>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||
// method: 'PUT',
|
||||
// body: transformFrontendToApi(data),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '상품권 수정에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: _id,
|
||||
serialNumber: _data.serialNumber,
|
||||
name: _data.name,
|
||||
faceValue: _data.faceValue,
|
||||
purchaseDate: _data.purchaseDate,
|
||||
usedDate: _data.usedDate || null,
|
||||
status: _data.status,
|
||||
entertainmentExpense: _data.entertainmentExpense,
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/loans/${id}`),
|
||||
method: 'PUT',
|
||||
body: transformFormToApi(data),
|
||||
transform: (d: LoanApiData) => transformApiToRecord(d),
|
||||
errorMessage: '상품권 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 삭제 (Mock) =====
|
||||
// ===== 상품권 삭제 =====
|
||||
export async function deleteGiftCertificate(
|
||||
_id: string
|
||||
id: string
|
||||
): Promise<ActionResult> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl(`/api/v1/gift-certificates/${id}`),
|
||||
// method: 'DELETE',
|
||||
// errorMessage: '상품권 삭제에 실패했습니다.',
|
||||
// });
|
||||
return { success: true };
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/loans/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '상품권 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 상품권 요약 통계 (Mock) =====
|
||||
export async function getGiftCertificateSummary(_params?: {
|
||||
// ===== 상품권 요약 통계 =====
|
||||
export async function getGiftCertificateSummary(params?: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}): Promise<ActionResult<{
|
||||
@@ -151,23 +113,31 @@ export async function getGiftCertificateSummary(_params?: {
|
||||
entertainmentCount: number;
|
||||
entertainmentAmount: number;
|
||||
}>> {
|
||||
// TODO: 실제 API 연동 시 교체
|
||||
// return executeServerAction({
|
||||
// url: buildApiUrl('/api/v1/gift-certificates/summary', { ... }),
|
||||
// transform: transformSummary,
|
||||
// errorMessage: '상품권 요약 조회에 실패했습니다.',
|
||||
// });
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalCount: 0,
|
||||
totalAmount: 0,
|
||||
holdingCount: 0,
|
||||
holdingAmount: 0,
|
||||
usedCount: 0,
|
||||
usedAmount: 0,
|
||||
entertainmentCount: 0,
|
||||
entertainmentAmount: 0,
|
||||
},
|
||||
};
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/loans/summary', {
|
||||
category: 'gift_certificate',
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
}),
|
||||
transform: (data: {
|
||||
total_count: number;
|
||||
total_amount: number;
|
||||
holding_count?: number;
|
||||
holding_amount?: number;
|
||||
used_count?: number;
|
||||
used_amount?: number;
|
||||
entertainment_count?: number;
|
||||
entertainment_amount?: number;
|
||||
}) => ({
|
||||
totalCount: data.total_count ?? 0,
|
||||
totalAmount: data.total_amount ?? 0,
|
||||
holdingCount: data.holding_count ?? 0,
|
||||
holdingAmount: data.holding_amount ?? 0,
|
||||
usedCount: data.used_count ?? 0,
|
||||
usedAmount: data.used_amount ?? 0,
|
||||
entertainmentCount: data.entertainment_count ?? 0,
|
||||
entertainmentAmount: data.entertainment_amount ?? 0,
|
||||
}),
|
||||
errorMessage: '상품권 요약 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user