Compare commits
41 Commits
main
...
bec933b3b4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bec933b3b4 | ||
|
|
1675f3edcf | ||
|
|
2fe47c86d3 | ||
|
|
00a6209347 | ||
| c18c68b6b7 | |||
| 03d129c32c | |||
| d6e3131c6a | |||
| 1d3805781c | |||
| b45c35a5e8 | |||
| b05e19e9f8 | |||
| 4331b84a63 | |||
| 0b81e9c1dd | |||
| f653960a30 | |||
| 888fae119f | |||
| f503e20030 | |||
| 0166601be8 | |||
| 83a23701a7 | |||
| bedfd1f559 | |||
| 8bcabafd08 | |||
| 5ff5093d7b | |||
|
|
23fa9c0ea2 | ||
|
|
cde9333652 | ||
|
|
7bb8699403 | ||
|
|
1bccaffe27 | ||
| 7a8d946960 | |||
| d1c530fdc1 | |||
| 0f53b407db | |||
| 0da6586bb6 | |||
| 2c87ac535a | |||
| 9ae2210388 | |||
| 33f763b48f | |||
|
|
8c0a655906 | ||
|
|
f4a7374f8c | ||
|
|
9d66d554ec | ||
|
|
b1686aaf66 | ||
| 2777ecf664 | |||
|
|
7aefbafb6f | ||
|
|
a83a8298d2 | ||
|
|
7af1c75eea | ||
|
|
8d8e2be001 | ||
|
|
8f9507a665 |
@@ -107,3 +107,10 @@ fixed_tools: []
|
||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||
# If null or missing, the value from the global config is used.
|
||||
symbol_info_budget:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
128
Jenkinsfile
vendored
128
Jenkinsfile
vendored
@@ -1,12 +1,6 @@
|
||||
pipeline {
|
||||
agent any
|
||||
|
||||
parameters {
|
||||
choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백')
|
||||
choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경')
|
||||
string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백')
|
||||
}
|
||||
|
||||
options {
|
||||
disableConcurrentBuilds()
|
||||
}
|
||||
@@ -14,66 +8,10 @@ pipeline {
|
||||
environment {
|
||||
DEPLOY_USER = 'hskwon'
|
||||
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
|
||||
PROD_SERVER = '211.117.60.189'
|
||||
}
|
||||
|
||||
stages {
|
||||
|
||||
// ── 롤백: 릴리스 목록 조회 ──
|
||||
stage('Rollback: List Releases') {
|
||||
when { expression { params.ACTION == 'rollback' } }
|
||||
steps {
|
||||
script {
|
||||
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/react' : '/home/webservice/react-stage'
|
||||
def pmName = params.ROLLBACK_TARGET == 'production' ? 'sam-front' : 'sam-front-stage'
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim()
|
||||
def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim()
|
||||
echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ==="
|
||||
echo "현재 활성: ${current}"
|
||||
echo "사용 가능:\n${releases}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 롤백: symlink 전환 ──
|
||||
stage('Rollback: Switch Release') {
|
||||
when { expression { params.ACTION == 'rollback' } }
|
||||
steps {
|
||||
script {
|
||||
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/react' : '/home/webservice/react-stage'
|
||||
def pmName = params.ROLLBACK_TARGET == 'production' ? 'sam-front' : 'sam-front-stage'
|
||||
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
def targetRelease = params.ROLLBACK_RELEASE
|
||||
if (!targetRelease?.trim()) {
|
||||
targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim()
|
||||
}
|
||||
|
||||
// 릴리스 존재 여부 확인
|
||||
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'"
|
||||
|
||||
slackSend channel: '#deploy_react', color: '#FF9800', tokenCredentialId: 'slack-token',
|
||||
message: "🔄 *react* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current &&
|
||||
cd /home/webservice && pm2 reload ${pmName}
|
||||
'
|
||||
"""
|
||||
|
||||
slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *react* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 일반 배포: Checkout ──
|
||||
stage('Checkout') {
|
||||
when { expression { params.ACTION == 'deploy' } }
|
||||
steps {
|
||||
checkout scm
|
||||
script {
|
||||
@@ -85,7 +23,6 @@ pipeline {
|
||||
}
|
||||
|
||||
stage('Prepare Env') {
|
||||
when { expression { params.ACTION == 'deploy' } }
|
||||
steps {
|
||||
script {
|
||||
if (env.BRANCH_NAME == 'main') {
|
||||
@@ -100,23 +37,16 @@ pipeline {
|
||||
}
|
||||
|
||||
stage('Install') {
|
||||
when { expression { params.ACTION == 'deploy' } }
|
||||
steps { sh 'npm install --prefer-offline' }
|
||||
}
|
||||
|
||||
stage('Build') {
|
||||
when { expression { params.ACTION == 'deploy' } }
|
||||
steps { sh 'npm run build' }
|
||||
}
|
||||
|
||||
// ── develop → 개발서버 배포 ──
|
||||
stage('Deploy Development') {
|
||||
when {
|
||||
allOf {
|
||||
branch 'develop'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
when { branch 'develop' }
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
@@ -133,21 +63,16 @@ pipeline {
|
||||
|
||||
// ── main → 운영서버 Stage 배포 ──
|
||||
stage('Deploy Stage') {
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
when { branch 'main' }
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
|
||||
rsync -az --delete \
|
||||
.next package.json next.config.ts public node_modules \
|
||||
${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/
|
||||
scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/
|
||||
scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||
ln -sfn /home/webservice/react-stage/releases/${RELEASE_ID} /home/webservice/react-stage/current &&
|
||||
cd /home/webservice && pm2 reload sam-front-stage 2>/dev/null || pm2 start react-stage/current/node_modules/.bin/next --name sam-front-stage -- start -p 3100 &&
|
||||
cd /home/webservice/react-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
|
||||
@@ -172,12 +97,7 @@ pipeline {
|
||||
|
||||
// ── main → Production 재빌드 (운영 환경변수) ──
|
||||
stage('Rebuild for Production') {
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
when { branch 'main' }
|
||||
steps {
|
||||
sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production"
|
||||
sh 'npm run build'
|
||||
@@ -186,21 +106,16 @@ pipeline {
|
||||
|
||||
// ── main → 운영서버 Production 배포 ──
|
||||
stage('Deploy Production') {
|
||||
when {
|
||||
allOf {
|
||||
branch 'main'
|
||||
expression { params.ACTION == 'deploy' }
|
||||
}
|
||||
}
|
||||
when { branch 'main' }
|
||||
steps {
|
||||
sshagent(credentials: ['deploy-ssh-key']) {
|
||||
sh """
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
|
||||
rsync -az --delete \
|
||||
.next package.json next.config.ts public node_modules \
|
||||
${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/
|
||||
scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/.env.production
|
||||
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||
${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/
|
||||
scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.production
|
||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
||||
ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
|
||||
cd /home/webservice && pm2 reload sam-front &&
|
||||
cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
|
||||
@@ -213,23 +128,12 @@ pipeline {
|
||||
|
||||
post {
|
||||
success {
|
||||
script {
|
||||
if (params.ACTION == 'deploy') {
|
||||
slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token',
|
||||
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
failure {
|
||||
script {
|
||||
if (params.ACTION == 'deploy') {
|
||||
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
} else {
|
||||
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *react* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
|
||||
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||
}
|
||||
}
|
||||
}
|
||||
172
claudedocs/[TASK-2026-03-03] daily-report-usd-section.md
Normal file
172
claudedocs/[TASK-2026-03-03] daily-report-usd-section.md
Normal file
@@ -0,0 +1,172 @@
|
||||
# 일일일보 — USD(외국환) 섹션 누락
|
||||
|
||||
**유형**: 프론트엔드 UI 누락
|
||||
**파일**: `src/components/accounting/DailyReport/index.tsx`
|
||||
**날짜**: 2026-03-03
|
||||
|
||||
---
|
||||
|
||||
## 현상
|
||||
|
||||
일일일보 페이지에 KRW(원화) 계좌만 표시되고, USD(외국환) 계좌 섹션이 없음.
|
||||
summary에 `usd_totals`(이월/입금/출금/잔액)이 내려오고, daily-accounts에 `currency: 'USD'` 항목도 내려오지만 UI에서 렌더링하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 원인
|
||||
|
||||
모든 테이블에서 `currency === 'KRW'` 필터만 적용 중:
|
||||
|
||||
```tsx
|
||||
// line 391 — 계좌별 상세
|
||||
filteredDailyAccounts.filter(item => item.currency === 'KRW')
|
||||
|
||||
// line 448 — 입금 테이블
|
||||
filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.income > 0)
|
||||
|
||||
// line 497 — 출금 테이블
|
||||
filteredDailyAccounts.filter(item => item.currency === 'KRW' && item.expense > 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 요구사항
|
||||
|
||||
기존 KRW 섹션과 동일한 구조로 USD 섹션 추가:
|
||||
|
||||
### 1. 일자별 상세 테이블에 USD 행 추가
|
||||
- 기존 KRW 계좌 목록 아래에 USD 계좌 목록 표시
|
||||
- 또는 KRW/USD 구분 소계 행으로 분리
|
||||
- 합계: `accountTotals.usd` 사용 (이미 계산 로직 있음, line 134-144)
|
||||
|
||||
### 2. 예금 입출금 내역에 USD 입금/출금 테이블 추가
|
||||
- 기존 KRW 입금/출금 아래에 USD 입금/출금 테이블 추가
|
||||
- 필터: `currency === 'USD' && item.income > 0` / `currency === 'USD' && item.expense > 0`
|
||||
- 금액 표시: USD 포맷 ($ 또는 달러 표기)
|
||||
|
||||
---
|
||||
|
||||
## 참고: 이미 준비된 데이터
|
||||
|
||||
### summary에서 내려오는 USD 데이터 (line 53-58)
|
||||
```typescript
|
||||
summary: {
|
||||
krwTotals: { carryover, income, expense, balance }, // ← 현재 사용 중
|
||||
usdTotals: { carryover, income, expense, balance }, // ← 미사용 (여기 추가)
|
||||
}
|
||||
```
|
||||
|
||||
### accountTotals 계산 로직 (line 134-144)
|
||||
```typescript
|
||||
// 이미 USD 합계 계산이 있음 — 사용만 하면 됨
|
||||
const usdAccounts = dailyAccounts.filter(item => item.currency === 'USD');
|
||||
const usdTotal = usdAccounts.reduce(
|
||||
(acc, item) => ({
|
||||
carryover: acc.carryover + item.carryover,
|
||||
income: acc.income + item.income,
|
||||
expense: acc.expense + item.expense,
|
||||
balance: acc.balance + item.balance,
|
||||
}),
|
||||
{ carryover: 0, income: 0, expense: 0, balance: 0 }
|
||||
);
|
||||
// accountTotals.usd 로 접근 가능
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 작업 범위
|
||||
|
||||
| 작업 | 설명 |
|
||||
|------|------|
|
||||
| 일자별 상세 테이블 | USD 계좌 행 추가 + USD 소계 행 |
|
||||
| 입금 테이블 | USD 입금 내역 추가 |
|
||||
| 출금 테이블 | USD 출금 내역 추가 |
|
||||
| 금액 포맷 | USD 표시 (달러 기호 또는 통화 표기) |
|
||||
|
||||
**수정 파일**: `src/components/accounting/DailyReport/index.tsx` (이 파일만)
|
||||
**새 코드 불필요**: API 데이터, 타입, 계산 로직 모두 이미 있음. 렌더링만 추가.
|
||||
|
||||
**상태**: ✅ 완료 (프론트엔드 렌더링 추가됨)
|
||||
|
||||
---
|
||||
|
||||
# CEO 대시보드 — 자금현황 데이터 정합성 이슈
|
||||
|
||||
**유형**: 백엔드 데이터 불일치
|
||||
**관련 API**: `GET /api/proxy/daily-report/summary`
|
||||
**관련 파일**: `sam-api/app/Services/DailyReportService.php`
|
||||
**날짜**: 2026-03-03
|
||||
|
||||
---
|
||||
|
||||
## 현상
|
||||
|
||||
CEO 대시보드 자금현황 섹션의 **입금 합계**가 입금 관리 페이지(`/accounting/deposits`)의 실제 데이터와 불일치.
|
||||
|
||||
| 항목 | 대시보드 summary API | 입금 관리 페이지 API | 차이 |
|
||||
|------|---------------------|---------------------|------|
|
||||
| 3월 입금 합계 | **200,000원** | **50,000원** (1건) | **150,000원 차이** |
|
||||
| 3월 출금 합계 | 50,000원 | 50,000원 (1건) | 일치 |
|
||||
|
||||
---
|
||||
|
||||
## 자금현황 각 수치의 의미 (현재 구조)
|
||||
|
||||
```
|
||||
현금성 자산 합계 (cash_asset_total)
|
||||
= KRW 활성 계좌들의 누적 잔액 합계 (당월만이 아닌 전체 잔고)
|
||||
├── 전월이월(carryover): 49,872,638원 ← 3월 이전 누적 (입금총액 - 출금총액)
|
||||
├── 당월입금(income): 200,000원 ← 3월 1일~오늘 입금
|
||||
├── 당월출금(expense): 50,000원 ← 3월 1일~오늘 출금
|
||||
└── 잔액(balance): 50,022,638원 = 이월+입금-출금
|
||||
|
||||
외국환(USD) 합계 (foreign_currency_total) = USD 계좌 잔액 합계
|
||||
입금 합계 = krw_totals.income (당월 KRW 입금만)
|
||||
출금 합계 = krw_totals.expense (당월 KRW 출금만)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 원인 분석
|
||||
|
||||
### 대시보드 summary API 쿼리 (DailyReportService.php line 77-80)
|
||||
```php
|
||||
$income = Deposit::where('tenant_id', $tenantId)
|
||||
->where('bank_account_id', $account->id)
|
||||
->whereBetween('deposit_date', [$startOfMonth, $endOfDay])
|
||||
->sum('amount');
|
||||
```
|
||||
|
||||
### 입금 관리 페이지 API 쿼리
|
||||
- 별도 컨트롤러/서비스에서 조회
|
||||
- 동일한 `deposits` 테이블을 읽지만, 조회 조건이 다를 수 있음
|
||||
|
||||
### 불일치 가능 원인
|
||||
1. **soft delete 차이**: summary는 soft-deleted 레코드 포함, 목록 API는 제외
|
||||
2. **tenant_id 조건 차이**: 두 API의 tenant 필터링이 다를 수 있음
|
||||
3. **E2E 테스트 데이터**: 테스트가 DB에 직접 삽입한 레코드가 목록 API에서는 필터됨
|
||||
4. **status 필터**: 입금 관리 목록에 status 조건이 추가되어 일부 제외
|
||||
|
||||
---
|
||||
|
||||
## 확인 필요 사항 (백엔드)
|
||||
|
||||
### 1. deposits 테이블 직접 조회
|
||||
```sql
|
||||
SELECT id, deposit_date, amount, bank_account_id, deleted_at, status
|
||||
FROM deposits
|
||||
WHERE tenant_id = [현재테넌트]
|
||||
AND bank_account_id = 1
|
||||
AND deposit_date BETWEEN '2026-03-01' AND '2026-03-03'
|
||||
ORDER BY id;
|
||||
```
|
||||
→ 실제 레코드 수와 합계 확인 (soft delete, status 포함)
|
||||
|
||||
### 2. 두 API의 쿼리 조건 비교
|
||||
- `DailyReportService::dailyAccounts()` — Deposit 모델 조건
|
||||
- 입금 관리 컨트롤러/서비스 — Deposit 모델 조건
|
||||
- 차이점 확인 (withTrashed, status 등)
|
||||
|
||||
### 3. 해결 방향
|
||||
- 두 API가 동일한 데이터 소스를 보도록 통일
|
||||
- 또는 대시보드에서 기존 입금/출금 관리 API를 재사용하여 데이터 일관성 확보
|
||||
@@ -0,0 +1,52 @@
|
||||
# 백엔드 API 수정 요청: 당월 예상 지출 상세 - 날짜 범위 필터링
|
||||
|
||||
## 엔드포인트
|
||||
`GET /api/v1/expected-expenses/dashboard-detail`
|
||||
|
||||
## 현재 상태
|
||||
- `transaction_type` 파라미터만 지원 (purchase, card, bill)
|
||||
- `start_date`, `end_date` 파라미터를 **무시**함
|
||||
- `items` 배열이 항상 **당월(현재 월)** 기준으로만 반환됨
|
||||
- `summary`도 당월 기준 고정 (total_amount, change_rate 등)
|
||||
- `monthly_trend`만 여러 월 데이터 포함 (최근 7개월)
|
||||
|
||||
## 요청 내용
|
||||
|
||||
### 1. 날짜 범위 필터 지원 추가
|
||||
```
|
||||
GET /api/v1/expected-expenses/dashboard-detail?transaction_type=purchase&start_date=2026-01-01&end_date=2026-01-31
|
||||
```
|
||||
|
||||
| 파라미터 | 타입 | 설명 | 기본값 |
|
||||
|---------|------|------|--------|
|
||||
| `start_date` | string (yyyy-MM-dd) | 조회 시작일 | 당월 1일 |
|
||||
| `end_date` | string (yyyy-MM-dd) | 조회 종료일 | 당월 말일 |
|
||||
| `search` | string | 거래처/항목 검색 | (없음) |
|
||||
|
||||
### 2. 기대 동작
|
||||
- `items`: `start_date` ~ `end_date` 범위의 거래 내역만 반환
|
||||
- `summary.total_amount`: 해당 기간의 합계
|
||||
- `summary.change_rate`: 해당 기간 vs 직전 동일 기간 비교
|
||||
- `vendor_distribution`: 해당 기간 기준 분포
|
||||
- `footer_summary`: 해당 기간 기준 합계
|
||||
- `monthly_trend`: 변경 불필요 (기존처럼 최근 7개월 유지)
|
||||
|
||||
### 3. 검색 필터 (선택)
|
||||
- `search` 파라미터로 거래처명/항목명 부분 검색
|
||||
|
||||
## 검증 데이터
|
||||
현재 `monthly_trend` 기준 데이터가 있는 월:
|
||||
- 11월: 14,101,865원
|
||||
- 12월: 35,241,935원
|
||||
- 1월: 3,000,000원
|
||||
- 2월: 1,650,000원
|
||||
|
||||
`start_date=2026-01-01&end_date=2026-01-31` 조회 시:
|
||||
- `items`: 1월 거래 내역 (현재 빈 배열)
|
||||
- `summary.total_amount`: 3,000,000 (현재 0)
|
||||
|
||||
## 프론트엔드 준비 상태
|
||||
- 프록시: 쿼리 파라미터 정상 전달 확인
|
||||
- 훅: `fetchData(cardId, { startDate, endDate, search })` 지원
|
||||
- 모달: 조회 버튼 + 날짜 필터 UI 완료
|
||||
- 백엔드 수정만 되면 즉시 동작
|
||||
@@ -0,0 +1,821 @@
|
||||
# CEO Dashboard 백엔드 API 명세서
|
||||
|
||||
**작성일**: 2026-03-03
|
||||
**기획서**: SAM_ERP_Storyboard_D1.7_260227.pdf p33~60
|
||||
**프론트엔드 타입**: `src/lib/api/dashboard/types.ts`
|
||||
**대상**: 백엔드 팀 (Laravel sam-api)
|
||||
|
||||
---
|
||||
|
||||
## 공통 규칙
|
||||
|
||||
### 응답 형식
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "조회 성공",
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### 인증
|
||||
- 모든 API는 `Authorization: Bearer {access_token}` 필수
|
||||
- Next.js API route 프록시(`/api/proxy/...`) 경유
|
||||
|
||||
### 캐싱
|
||||
- `sam_stat` 테이블 5분 캐시 (기존 구현 유지)
|
||||
- 대시보드 API는 실시간성보다 성능 우선
|
||||
|
||||
### 날짜/기간 파라미터 규칙
|
||||
- 날짜: `YYYY-MM-DD` (예: `2026-03-03`)
|
||||
- 월: `YYYY-MM` (예: `2026-03`)
|
||||
- 분기: `year=2026&quarter=1`
|
||||
- 기본값: 파라미터 미지정 시 **당월/당분기** 기준
|
||||
|
||||
---
|
||||
|
||||
## 검수 중 발견된 누락 API
|
||||
|
||||
### N1. 오늘의 이슈 — 과거 이력 저장 및 조회
|
||||
**우선순위**: 상
|
||||
**페이지**: p34
|
||||
**현상**: `GET /api/v1/today-issues/summary?date=2026-02-17` 호출 시 항상 `{"items":[], "total_count":0}` 반환. 과거 이슈를 저장하는 구조가 없어서 이전 이슈 탭이 항상 빈 목록.
|
||||
|
||||
**요구사항**:
|
||||
1. **이슈 이력 테이블** 필요 (예: `dashboard_issue_history`)
|
||||
- 매일 자정(또는 배치) 시점에 당일 이슈 스냅샷 저장
|
||||
- 또는 이슈 발생 시점에 이력 테이블에 INSERT
|
||||
2. **기존 API 수정**: `GET /api/v1/today-issues/summary`
|
||||
- `date` 파라미터가 있을 때 해당 날짜의 이력 데이터 반환
|
||||
- `date` 파라미터가 없으면 기존대로 실시간 집계
|
||||
|
||||
**Response** (기존 `TodayIssueApiResponse`와 동일):
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "issue-20260302-001",
|
||||
"badge": "수주",
|
||||
"notification_type": "sales_order",
|
||||
"content": "대한건설 수주 3건 접수",
|
||||
"time": "14:30",
|
||||
"date": "2026-03-02",
|
||||
"path": "/ko/sales/order-management",
|
||||
"needs_approval": false
|
||||
}
|
||||
],
|
||||
"total_count": 5
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 배치 저장 방식: `App\Console\Commands\SnapshotDailyIssues` (Schedule::daily)
|
||||
- 또는 이벤트 기반: 수주/채권/재고 변동 시 `dashboard_issue_history` INSERT
|
||||
|
||||
### N2. 자금현황 — 전일 대비 변동률 (daily_change)
|
||||
**우선순위**: 중
|
||||
**페이지**: p33
|
||||
**현상**: `GET /api/v1/daily-report/summary` 응답에 `daily_change` 필드가 없음. 프론트엔드에서 하드코딩 fallback 값(+5.2%, +2.1%, +12.0%, -8.0%)을 사용 중.
|
||||
|
||||
**요구사항**:
|
||||
1. **기존 API 수정**: `GET /api/v1/daily-report/summary`
|
||||
2. 응답에 `daily_change` 객체 추가
|
||||
3. 각 항목의 전일 대비 변동률(%) 계산 로직:
|
||||
- `cash_asset_change_rate`: (오늘 현금성자산 - 어제 현금성자산) / 어제 현금성자산 × 100
|
||||
- `foreign_currency_change_rate`: (오늘 외국환 - 어제 외국환) / 어제 외국환 × 100
|
||||
- `income_change_rate`: (오늘 입금 - 어제 입금) / 어제 입금 × 100
|
||||
- `expense_change_rate`: (오늘 지출 - 어제 지출) / 어제 지출 × 100
|
||||
4. 어제 데이터 없을 시 해당 필드 `null` (프론트에서 fallback 처리)
|
||||
|
||||
**Response** (기존 응답에 `daily_change` 추가):
|
||||
```json
|
||||
{
|
||||
"date": "2026-03-03",
|
||||
"day_of_week": "화",
|
||||
"cash_asset_total": 1250000000,
|
||||
"foreign_currency_total": 85000,
|
||||
"krw_totals": { "income": 45000000, "expense": 32000000, "balance": 1250000000 },
|
||||
"daily_change": {
|
||||
"cash_asset_change_rate": 5.2,
|
||||
"foreign_currency_change_rate": 2.1,
|
||||
"income_change_rate": 12.0,
|
||||
"expense_change_rate": -8.0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `DailyReportService`에서 전일 데이터 조회 추가
|
||||
- `sam_stat` 캐시 테이블에 전일 스냅샷 있으면 활용
|
||||
- 프론트 타입: `DailyChangeRate` (`src/lib/api/dashboard/types.ts:23`)
|
||||
|
||||
### N3. 일일일보 — daily-accounts에 입출금관리 데이터 미반영
|
||||
**우선순위**: 상
|
||||
**페이지**: 일일일보 페이지 (`/ko/accounting/daily-report`)
|
||||
**현상**: 입금관리/출금관리에서 당일 거래를 등록하면 대시보드 자금현황(`daily-report/summary`)의 합계에는 즉시 반영되지만, 일일일보 페이지의 계좌별 상세 테이블(`daily-report/daily-accounts`)에는 표시되지 않음. (출금 테스트로 확인됨, 입금도 동일 구조로 미반영 추정)
|
||||
|
||||
**영향 범위**:
|
||||
| 데이터 | 관리 테이블 | summary (합계) | daily-accounts (상세) |
|
||||
|--------|-----------|:-:|:-:|
|
||||
| 입금 | `deposits` (`/api/v1/deposits`) | ✅ 반영 추정 | ❌ 미반영 추정 |
|
||||
| 출금 | `withdrawals` (`/api/v1/withdrawals`) | ✅ 반영 확인 | ❌ 미반영 확인 |
|
||||
| 외국환 (USD) | 별도 관리 페이지 미확인 | ✅ 반영 | ❓ 확인 필요 |
|
||||
|
||||
**원인 분석**:
|
||||
- `GET /api/v1/daily-report/summary` → `krw_totals`에 `deposits`/`withdrawals` 테이블 데이터 포함 ✅
|
||||
- `GET /api/v1/daily-report/daily-accounts` → `bank_accounts` 단위 집계만 반환, `deposits`/`withdrawals` 테이블 미포함 ❌
|
||||
|
||||
**데이터 흐름**:
|
||||
```
|
||||
입금관리 등록 → deposits 테이블 INSERT (bank_account_id 포함)
|
||||
출금관리 등록 → withdrawals 테이블 INSERT (bank_account_id 포함)
|
||||
├─ summary API → krw_totals.income/expense에 합산 → 대시보드 ✅
|
||||
└─ daily-accounts API → bank_accounts 기준만 조회 → 일일일보 상세 ❌
|
||||
```
|
||||
|
||||
**요구사항**:
|
||||
1. `GET /api/v1/daily-report/daily-accounts` 수정
|
||||
2. 각 계좌별로 `deposits` 테이블의 당일 income과 `withdrawals` 테이블의 당일 expense를 합산
|
||||
3. 또는 입금/출금 등록 시 해당 계좌의 거래 내역(`bank_account_transactions`)에도 자동 반영
|
||||
|
||||
**해결 방안 (택 1)**:
|
||||
- **방안 A** (daily-accounts 쿼리 수정): `bank_accounts` LEFT JOIN `deposits`/`withdrawals` WHERE date = 당일 → 계좌별 income/expense에 합산
|
||||
- **방안 B** (트랜잭션 연동): 입금/출금 등록 시 `bank_account_transactions`에도 INSERT → daily-accounts가 자연스럽게 포함
|
||||
|
||||
**Response** (기존 `DailyAccountItemApi[]`와 동일, 데이터만 보완):
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "acc_1",
|
||||
"category": "우리은행 123-456",
|
||||
"match_status": "matched",
|
||||
"carryover": 50000000,
|
||||
"income": 1000000,
|
||||
"expense": 50000,
|
||||
"balance": 50950000,
|
||||
"currency": "KRW"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `DailyReportService`의 `getDailyAccounts()` 메서드 확인
|
||||
- `deposits` 테이블: `deposit.bank_account_id`로 해당 계좌 income 합산
|
||||
- `withdrawals` 테이블: `withdrawal.bank_account_id`로 해당 계좌 expense 합산
|
||||
- USD 계좌도 동일 패턴 적용 필요
|
||||
|
||||
### N4. 현황판 `purchases`(발주) — path 오류 + 데이터 정합성 이슈
|
||||
**우선순위**: 중
|
||||
**페이지**: p34 (현황판)
|
||||
|
||||
#### 이슈 A: path 하드코딩 오류
|
||||
**현상**: `purchases` 항목의 실제 데이터는 `purchases` 테이블(매입, 공통)에서 조회하면서, path는 건설 모듈 경로로 하드코딩되어 있음.
|
||||
|
||||
**문제 코드** (`StatusBoardService.php` — `getPurchaseStatus()`):
|
||||
```php
|
||||
$count = Purchase::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('status', 'draft')
|
||||
->count();
|
||||
|
||||
return [
|
||||
'id' => 'purchases',
|
||||
'label' => '발주',
|
||||
'path' => '/construction/order/order-management', // ← 매입 데이터인데 건설 경로
|
||||
];
|
||||
```
|
||||
|
||||
- 데이터 출처: `purchases` 테이블 (모든 테넌트 공통 매입 테이블)
|
||||
- path: `/construction/order/order-management` (건설 전용 페이지)
|
||||
- **데이터와 path가 불일치** — 매입 draft 건수를 보여주면서 건설 발주 페이지로 링크
|
||||
|
||||
**현재 프론트 임시 대응**: `status-issue.ts`에서 `/accounting/purchase`(매입관리)로 오버라이드 중
|
||||
|
||||
**요구사항**:
|
||||
1. path를 `/accounting/purchase`로 변경 (데이터 출처와 일치시키기)
|
||||
2. 또는 테넌트 업종에 따라 path 동적 분기 (건설: `/construction/order/order-management`, 기타: `/accounting/purchase`)
|
||||
3. 라벨도 재검토: "발주"가 맞는지, "매입(임시저장)"이 더 정확한지
|
||||
|
||||
#### 이슈 B: 데이터 정합성 의심
|
||||
**현상**: StatusBoard API에서 `purchases` count=**9건** 반환, 하지만 매입관리 페이지(`/accounting/purchase`)에서 전체 조회 시 **1건**만 표시.
|
||||
|
||||
**확인 사항** (DB 직접 확인 필요):
|
||||
```sql
|
||||
-- 현재 테넌트의 purchases 테이블 전체 건수
|
||||
SELECT COUNT(*), status FROM purchases WHERE tenant_id = {현재 테넌트 ID} GROUP BY status;
|
||||
|
||||
-- draft 상태 건수 (StatusBoard가 조회하는 조건)
|
||||
SELECT COUNT(*) FROM purchases WHERE tenant_id = {현재 테넌트 ID} AND status = 'draft';
|
||||
```
|
||||
|
||||
**가능한 원인**:
|
||||
1. StatusBoard와 매입관리 페이지가 다른 tenant_id 스코프로 조회
|
||||
2. DummyDataSeeder가 다른 tenant_id로 데이터 생성
|
||||
3. 매입관리 API에 추가 필터 조건이 있어서 draft 건이 제외됨
|
||||
4. StatusBoard가 실제와 다른 데이터를 집계
|
||||
|
||||
**기대 결과**: StatusBoard 9건 클릭 → 매입관리 페이지에서 9건 확인 가능해야 함
|
||||
|
||||
---
|
||||
|
||||
## 신규 API (10개)
|
||||
|
||||
### 1. 매출 현황 Summary
|
||||
**우선순위**: 중
|
||||
**페이지**: p39
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/sales/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| year | int | N | 조회 연도 (기본: 당해) |
|
||||
| month | int | N | 조회 월 (기본: 당월) |
|
||||
|
||||
**Response** (`SalesStatusApiResponse`):
|
||||
```json
|
||||
{
|
||||
"cumulative_sales": 312300000,
|
||||
"achievement_rate": 94.5,
|
||||
"yoy_change": 12.5,
|
||||
"monthly_sales": 312300000,
|
||||
"monthly_trend": [
|
||||
{ "month": "2026-08", "label": "8월", "amount": 250000000 },
|
||||
{ "month": "2026-09", "label": "9월", "amount": 280000000 }
|
||||
],
|
||||
"client_sales": [
|
||||
{ "name": "대한건설", "amount": 95000000 },
|
||||
{ "name": "삼성테크", "amount": 78000000 }
|
||||
],
|
||||
"daily_items": [
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"client": "대한건설",
|
||||
"item": "스크린 외",
|
||||
"amount": 25000000,
|
||||
"status": "deposited"
|
||||
}
|
||||
],
|
||||
"daily_total": 312300000
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 매출: `sales_orders` 합계 (confirmed 상태)
|
||||
- 달성률: 매출 목표 대비 (`sales_targets` 테이블)
|
||||
- YoY: 전년 동월 대비 변화율
|
||||
- 거래처별: GROUP BY vendor_id → TOP 5
|
||||
- status 코드: `deposited` (입금완료), `unpaid` (미입금), `partial` (부분입금)
|
||||
|
||||
---
|
||||
|
||||
### 2. 매입 현황 Summary
|
||||
**우선순위**: 중
|
||||
**페이지**: p40
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/purchases/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| year | int | N | 조회 연도 (기본: 당해) |
|
||||
| month | int | N | 조회 월 (기본: 당월) |
|
||||
|
||||
**Response** (`PurchaseStatusApiResponse`):
|
||||
```json
|
||||
{
|
||||
"cumulative_purchase": 312300000,
|
||||
"unpaid_amount": 312300000,
|
||||
"yoy_change": -12.5,
|
||||
"monthly_trend": [
|
||||
{ "month": "2026-08", "label": "8월", "amount": 180000000 }
|
||||
],
|
||||
"material_ratio": [
|
||||
{ "name": "원자재", "value": 55, "percentage": 55, "color": "#3b82f6" },
|
||||
{ "name": "부자재", "value": 35, "percentage": 35, "color": "#10b981" },
|
||||
{ "name": "소모품", "value": 10, "percentage": 10, "color": "#f59e0b" }
|
||||
],
|
||||
"daily_items": [
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"supplier": "한국철강",
|
||||
"item": "철판 외",
|
||||
"amount": 45000000,
|
||||
"status": "paid"
|
||||
}
|
||||
],
|
||||
"daily_total": 312300000
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 매입: `purchase_orders` 합계
|
||||
- 미결제: 결제 미완료 건 합계
|
||||
- 원자재/부자재/소모품: `item_categories` 기준 분류
|
||||
- status 코드: `paid` (결제완료), `unpaid` (미결제), `partial` (부분결제)
|
||||
|
||||
---
|
||||
|
||||
### 3. 생산 현황 Summary
|
||||
**우선순위**: 상
|
||||
**페이지**: p41
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/production/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) |
|
||||
|
||||
**Response** (`DailyProductionApiResponse`):
|
||||
```json
|
||||
{
|
||||
"date": "2026-02-23",
|
||||
"day_of_week": "월요일",
|
||||
"processes": [
|
||||
{
|
||||
"process_name": "스크린",
|
||||
"total_work": 10,
|
||||
"todo": 3,
|
||||
"in_progress": 4,
|
||||
"completed": 3,
|
||||
"urgent": 2,
|
||||
"sub_line": 1,
|
||||
"regular": 5,
|
||||
"worker_count": 8,
|
||||
"work_items": [
|
||||
{
|
||||
"id": "wo_1",
|
||||
"order_no": "SO-2026-001",
|
||||
"client": "대한건설",
|
||||
"product": "스크린 A형",
|
||||
"quantity": 50,
|
||||
"status": "in_progress"
|
||||
}
|
||||
],
|
||||
"workers": [
|
||||
{
|
||||
"name": "김철수",
|
||||
"assigned": 5,
|
||||
"completed": 3,
|
||||
"rate": 60
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"shipment": {
|
||||
"expected_amount": 150000000,
|
||||
"expected_count": 12,
|
||||
"actual_amount": 120000000,
|
||||
"actual_count": 9
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 공정: `work_processes` 테이블 (스크린, 슬랫, 절곡 등)
|
||||
- 작업: `work_orders` JOIN `work_process_id`
|
||||
- status: `pending` → todo, `in_progress`, `completed`
|
||||
- urgent: 납기 3일 이내
|
||||
- 출고: `shipments` 테이블 (당일 예상 vs 실적)
|
||||
|
||||
---
|
||||
|
||||
### 4. 출고 현황 (생산 현황에 포함)
|
||||
**우선순위**: 하
|
||||
**페이지**: p41
|
||||
|
||||
생산 현황 API의 `shipment` 필드로 포함됨. 별도 API 불필요.
|
||||
|
||||
---
|
||||
|
||||
### 5. 미출고 내역
|
||||
**우선순위**: 하
|
||||
**페이지**: p42
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/unshipped/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| days | int | N | 납기 N일 이내 (기본: 30) |
|
||||
|
||||
**Response** (`UnshippedApiResponse`):
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"id": "us_1",
|
||||
"port_no": "P-2026-001",
|
||||
"site_name": "강남 현장",
|
||||
"order_client": "대한건설",
|
||||
"due_date": "2026-02-25",
|
||||
"days_left": 2
|
||||
}
|
||||
],
|
||||
"total_count": 7
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `shipment_items` WHERE shipped_at IS NULL AND due_date >= NOW()
|
||||
- days_left: DATEDIFF(due_date, NOW())
|
||||
- ORDER BY due_date ASC (납기 임박 순)
|
||||
|
||||
---
|
||||
|
||||
### 6. 시공 현황
|
||||
**우선순위**: 중
|
||||
**페이지**: p42
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/construction/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| month | int | N | 조회 월 (기본: 당월) |
|
||||
|
||||
**Response** (`ConstructionApiResponse`):
|
||||
```json
|
||||
{
|
||||
"this_month": 15,
|
||||
"completed": 5,
|
||||
"items": [
|
||||
{
|
||||
"id": "cs_1",
|
||||
"site_name": "강남 현장",
|
||||
"client": "대한건설",
|
||||
"start_date": "2026-02-01",
|
||||
"end_date": "2026-02-28",
|
||||
"progress": 85,
|
||||
"status": "in_progress"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `constructions` 테이블
|
||||
- status: `in_progress`, `scheduled`, `completed`
|
||||
- completed: 최근 7일 이내 완료 건
|
||||
|
||||
---
|
||||
|
||||
### 7. 근태 현황
|
||||
**우선순위**: 중
|
||||
**페이지**: p43
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/attendance/summary
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| date | string | N | 조회 일자 (기본: 오늘, YYYY-MM-DD) |
|
||||
|
||||
**Response** (`DailyAttendanceApiResponse`):
|
||||
```json
|
||||
{
|
||||
"present": 42,
|
||||
"on_leave": 3,
|
||||
"late": 1,
|
||||
"absent": 0,
|
||||
"employees": [
|
||||
{
|
||||
"id": "emp_1",
|
||||
"department": "생산부",
|
||||
"position": "과장",
|
||||
"name": "김철수",
|
||||
"status": "present"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- `attendances` WHERE date = :date
|
||||
- status: `present`, `on_leave`, `late`, `absent`
|
||||
- employees: 이상 상태(late, absent, on_leave) 위주 표시
|
||||
|
||||
---
|
||||
|
||||
### 8. 일별 매출 내역
|
||||
**우선순위**: 하
|
||||
**페이지**: p47 (설정 팝업에서 별도 ON/OFF)
|
||||
|
||||
매출 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시:
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/sales/daily
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| start_date | string | N | 시작일 (기본: 당월 1일) |
|
||||
| end_date | string | N | 종료일 (기본: 오늘) |
|
||||
| page | int | N | 페이지 (기본: 1) |
|
||||
| per_page | int | N | 건수 (기본: 20) |
|
||||
|
||||
---
|
||||
|
||||
### 9. 일별 매입 내역
|
||||
**우선순위**: 하
|
||||
|
||||
매입 현황 API의 `daily_items`로 이미 포함. 별도 API 필요 시:
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/purchases/daily
|
||||
```
|
||||
|
||||
(매출 일별과 동일 구조)
|
||||
|
||||
---
|
||||
|
||||
### 10. 접대비 상세
|
||||
**우선순위**: 상
|
||||
**페이지**: p53-54
|
||||
|
||||
```
|
||||
GET /api/v1/dashboard/entertainment/detail
|
||||
```
|
||||
|
||||
**Query Params**:
|
||||
| 파라미터 | 타입 | 필수 | 설명 |
|
||||
|---------|------|------|------|
|
||||
| year | int | N | 연도 |
|
||||
| quarter | int | N | 분기 (1-4) |
|
||||
| limit_type | string | N | annual/quarterly |
|
||||
| company_type | string | N | large/medium/small |
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total_used": 10000000,
|
||||
"annual_limit": 40120000,
|
||||
"remaining": 30120000,
|
||||
"usage_rate": 24.9
|
||||
},
|
||||
"limit_calculation": {
|
||||
"base_limit": 36000000,
|
||||
"revenue_additional": 4120000,
|
||||
"total_limit": 40120000,
|
||||
"revenue": 2060000000,
|
||||
"company_type": "medium"
|
||||
},
|
||||
"quarterly_status": [
|
||||
{
|
||||
"quarter": 1,
|
||||
"label": "1분기",
|
||||
"limit": 10030000,
|
||||
"used": 3500000,
|
||||
"remaining": 6530000,
|
||||
"exceeded": 0
|
||||
}
|
||||
],
|
||||
"transactions": [
|
||||
{
|
||||
"id": 1,
|
||||
"date": "2026-01-15",
|
||||
"user_name": "홍길동",
|
||||
"merchant_name": "강남식당",
|
||||
"amount": 350000,
|
||||
"counterpart": "대한건설",
|
||||
"receipt_type": "법인카드",
|
||||
"risk_flags": ["high_amount"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 수정 API (6개)
|
||||
|
||||
### 1. 가지급금 Summary (수정)
|
||||
**현재**: 카드/가지급금/법인세/종합세
|
||||
**변경**: 카드/경조사/상품권/접대비/총합계 (5카드)
|
||||
|
||||
```
|
||||
GET /api/proxy/card-transactions/summary
|
||||
```
|
||||
|
||||
**Response 변경**:
|
||||
```json
|
||||
{
|
||||
"cards": [
|
||||
{ "id": "cm1", "label": "카드", "amount": 3123000, "sub_label": "미정리 5건", "count": 5 },
|
||||
{ "id": "cm2", "label": "경조사", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 },
|
||||
{ "id": "cm3", "label": "상품권", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 },
|
||||
{ "id": "cm4", "label": "접대비", "amount": 3123000, "sub_label": "미증빙 5건", "count": 5 },
|
||||
{ "id": "cm_total", "label": "총 가지급금 합계", "amount": 350000000 }
|
||||
],
|
||||
"check_points": [
|
||||
{
|
||||
"id": "cm-cp1",
|
||||
"type": "warning",
|
||||
"message": "법인카드 사용 총 850만원이 가지급금으로 전환되었습니다.",
|
||||
"highlights": [{ "text": "850만원", "color": "red" }]
|
||||
}
|
||||
],
|
||||
"warning_banner": "가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의"
|
||||
}
|
||||
```
|
||||
|
||||
**Laravel 힌트**:
|
||||
- 분류: `card_transactions.category` 기준 (card/congratulation/gift_card/entertainment)
|
||||
- 미정리/미증빙: `evidence_status = 'pending'` COUNT
|
||||
|
||||
---
|
||||
|
||||
### 2. 접대비 Summary (수정)
|
||||
**현재**: 매출/한도/잔여한도/사용금액
|
||||
**변경**: 주말심야/기피업종/고액결제/증빙미비 (리스크 4종)
|
||||
|
||||
```
|
||||
GET /api/proxy/entertainment/summary
|
||||
```
|
||||
|
||||
**Response 변경**:
|
||||
```json
|
||||
{
|
||||
"cards": [
|
||||
{ "id": "et1", "label": "주말/심야", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "et2", "label": "기피업종 (유흥, 귀금속 등)", "amount": 3123000, "sub_label": "불인정 5건", "count": 5 },
|
||||
{ "id": "et3", "label": "고액 결제", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "et4", "label": "증빙 미비", "amount": 3123000, "sub_label": "5건", "count": 5 }
|
||||
],
|
||||
"check_points": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**리스크 감지 로직** (p60 참조):
|
||||
- 주말/심야: 토~일, 22:00~06:00 거래
|
||||
- 기피업종: MCC 코드 기반 (유흥업소 7273, 귀금속 5944, 골프장 7941 등)
|
||||
- 고액 결제: 설정 금액(기본 50만원) 초과
|
||||
- 증빙 미비: 적격증빙(세금계산서/카드매출전표) 없는 건
|
||||
|
||||
---
|
||||
|
||||
### 3. 복리후생비 Summary (수정)
|
||||
**현재**: 한도/잔여한도/사용금액
|
||||
**변경**: 비과세한도초과/사적사용의심/특정인편중/항목별한도초과 (리스크 4종)
|
||||
|
||||
```
|
||||
GET /api/proxy/welfare/summary
|
||||
```
|
||||
|
||||
**Response 변경**:
|
||||
```json
|
||||
{
|
||||
"cards": [
|
||||
{ "id": "wf1", "label": "비과세 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "wf2", "label": "사적 사용 의심", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "wf3", "label": "특정인 편중", "amount": 3123000, "sub_label": "5건", "count": 5 },
|
||||
{ "id": "wf4", "label": "항목별 한도 초과", "amount": 3123000, "sub_label": "5건", "count": 5 }
|
||||
],
|
||||
"check_points": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**리스크 감지 로직**:
|
||||
- 비과세 한도 초과: 항목별 비과세 기준 초과 (식대 20만원, 교통비 10만원 등)
|
||||
- 사적 사용 의심: 주말/야간 + 비업무 업종 조합
|
||||
- 특정인 편중: 직원별 사용액 편차 > 평균의 200%
|
||||
- 항목별 한도 초과: 설정 금액 초과
|
||||
|
||||
---
|
||||
|
||||
### 4. 가지급금 Detail (수정)
|
||||
|
||||
기존 `LoanDashboardApiResponse`에 AI분류 컬럼 추가.
|
||||
|
||||
```
|
||||
GET /api/v1/loans/dashboard
|
||||
```
|
||||
|
||||
**Response 추가 필드**:
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"...기존 필드...",
|
||||
"ai_category": "카드",
|
||||
"evidence_status": "미증빙"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 복리후생비 Detail (수정)
|
||||
|
||||
기존 `WelfareDetailApiResponse`에 계산방식 파라미터 추가.
|
||||
|
||||
```
|
||||
GET /api/proxy/welfare/detail?calculation_type=fixed&fixed_amount_per_month=200000
|
||||
```
|
||||
|
||||
(기존 구현 유지, 계산 파라미터만 반영 확인)
|
||||
|
||||
---
|
||||
|
||||
### 6. 부가세 Detail (수정)
|
||||
|
||||
기존 `VatApiResponse`에 신고기간 파라미터 반영.
|
||||
|
||||
```
|
||||
GET /api/proxy/vat/summary?period_type=quarter&year=2026&period=1
|
||||
```
|
||||
|
||||
(기존 구현 유지, 기간별 필터링 확인)
|
||||
|
||||
---
|
||||
|
||||
## 리스크 감지 로직 참고 (p58-60)
|
||||
|
||||
### MCC 코드 기피업종
|
||||
| MCC | 업종 | 분류 |
|
||||
|-----|------|------|
|
||||
| 7273 | 유흥업소 | 기피업종 |
|
||||
| 5944 | 귀금속 | 기피업종 |
|
||||
| 7941 | 골프장 | 기피업종 |
|
||||
| 5813 | 주점 | 기피업종 |
|
||||
| 7011 | 호텔/리조트 | 주의업종 |
|
||||
|
||||
### 리스크 판별 규칙
|
||||
```
|
||||
규칙1: 시간대 이상 → 22:00~06:00 또는 토~일
|
||||
규칙2: 업종 이상 → MCC 기피업종 해당
|
||||
규칙3: 금액 이상 → 설정 금액 초과 (기본 50만원)
|
||||
규칙4: 빈도 이상 → 월 10회 이상 동일 업종
|
||||
규칙5: 증빙 미비 → 적격증빙 없음
|
||||
|
||||
리스크 등급:
|
||||
- 2개 이상 해당 → 🔴 고위험
|
||||
- 1개 해당 → 🟡 주의
|
||||
- 0개 → 🟢 정상
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 계산 공식 참고
|
||||
|
||||
### 가지급금 인정이자 (p58)
|
||||
```
|
||||
인정이자 = 가지급금잔액 × (4.6% / 365) × 경과일수
|
||||
법인세 추가 = 인정이자 × 19%
|
||||
대표자 소득세 = 인정이자 × 35%
|
||||
```
|
||||
|
||||
### 접대비 손금한도 (p59)
|
||||
```
|
||||
기본한도:
|
||||
일반법인: 1,200만원/년
|
||||
중소기업: 3,600만원/년
|
||||
|
||||
수입금액별 추가:
|
||||
100억 이하: 수입금액 × 0.2%
|
||||
100~500억: 2,000만원 + (수입금액-100억) × 0.1%
|
||||
500억 초과: 6,000만원 + (수입금액-500억) × 0.03%
|
||||
```
|
||||
|
||||
### 복리후생비 (p60)
|
||||
```
|
||||
방식1 (정액): 직원수 × 월정액 × 12
|
||||
방식2 (비율): 연봉총액 × 비율%
|
||||
|
||||
비과세 한도:
|
||||
식대: 20만원/월
|
||||
교통비: 10만원/월
|
||||
경조사: 5만원/건
|
||||
건강검진: 연간 총액/12 환산
|
||||
교육훈련: 8만원/월
|
||||
복지포인트: 10만원/월
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 우선순위 정리
|
||||
|
||||
| 우선순위 | API | 이유 |
|
||||
|---------|-----|------|
|
||||
| 🔴 상 | 접대비 summary 수정, 복리후생비 summary 수정 | D1.7 카드 구조 변경 |
|
||||
| 🔴 상 | 가지급금 summary 수정 | D1.7 카드 구조 변경 |
|
||||
| 🔴 상 | 접대비 detail 신규 | 모달 확장 |
|
||||
| 🟡 중 | 매출 현황, 매입 현황, 시공 현황, 근태 현황 | 신규 섹션 |
|
||||
| 🟡 중 | 생산 현황 | 복잡한 공정 집계 |
|
||||
| 🟢 하 | 미출고 내역, 일별 매출/매입 | 단순 조회 |
|
||||
BIN
claudedocs/architecture/.DS_Store
vendored
Normal file
BIN
claudedocs/architecture/.DS_Store
vendored
Normal file
Binary file not shown.
@@ -0,0 +1,176 @@
|
||||
# CEO Dashboard 분석 (기획서 D1.7 기준)
|
||||
|
||||
**기획서**: `SAM_ERP_Storyboard_D1.7_260227.pdf` p33~60
|
||||
**분석일**: 2026-02-27
|
||||
**상태**: 기획서 분석 완료, 구현 대기
|
||||
|
||||
---
|
||||
|
||||
## 1. 전체 구성
|
||||
|
||||
| 구분 | 페이지 | 수량 |
|
||||
|------|--------|------|
|
||||
| 메인 대시보드 섹션 | p33~43 | 20개 |
|
||||
| 상세 모달 | p44~57 | 10개 |
|
||||
| 참고 자료 (계산공식) | p58~60 | 3페이지 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 섹션별 현황 (20개)
|
||||
|
||||
### API 연동 완료 (11개)
|
||||
|
||||
| # | 섹션 | 페이지 | hook | API endpoint |
|
||||
|---|------|--------|------|-------------|
|
||||
| 1 | 오늘의 이슈 | p33 | useTodayIssue | today-issues/summary |
|
||||
| 2 | 자금 현황 | p33-34 | useCEODashboard | daily-report/summary |
|
||||
| 3 | 현황판 | p34 | useStatusBoard | status-board/summary |
|
||||
| 4 | 당월 예상 지출 | p34-35 | useMonthlyExpense | expected-expenses/summary |
|
||||
| 5 | 가지급금 현황 | p35 | useCardManagement | card-transactions/summary + 2개 |
|
||||
| 6 | 접대비 현황 | p35-36 | useEntertainment | entertainment/summary |
|
||||
| 7 | 복리후생비 현황 | p36 | useWelfare | welfare/summary |
|
||||
| 8 | 미수금 현황 | p36 | useReceivable | receivables/summary |
|
||||
| 9 | 채권추심 현황 | p37 | useDebtCollection | bad-debts/summary |
|
||||
| 10 | 부가세 현황 | p37-38 | useVat | vat/summary |
|
||||
| 11 | 캘린더 | p38 | useCalendar | calendar/schedules |
|
||||
|
||||
### Mock 데이터만 (9개) - API 신규 필요
|
||||
|
||||
| # | 섹션 | 페이지 | 필요 데이터 |
|
||||
|---|------|--------|-----------|
|
||||
| 12 | 매출 현황 | p39 | 누적매출, 달성률, YoY, 당월매출 + 차트2 + 테이블 |
|
||||
| 13 | 일별 매출 내역 | p47(설정) | 매출일, 거래처, 매출금액 (🆕 신규 섹션) |
|
||||
| 14 | 매입 현황 | p40 | 누적매입, 미결제, YoY + 차트2 + 테이블 |
|
||||
| 15 | 일별 매입 내역 | p47(설정) | 매입일, 거래처, 매입금액 (🆕 신규 섹션) |
|
||||
| 16 | 생산 현황 | p41 | 공정별(스크린/슬랫/절곡) 집계 + 작업자현황 |
|
||||
| 17 | 출고 현황 | p41 | 예상출고 7일/30일 금액+건수 |
|
||||
| 18 | 미출고 내역 | p42 | 로트번호, 현장명, 수주처, 잔량, 납기일 |
|
||||
| 19 | 시공 현황 | p42 | 진행/완료(7일이내) + 현장카드 |
|
||||
| 20 | 근태 현황 | p43 | 출근/휴가/지각/결근 + 직원테이블 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 🔴 D1.7 핵심 변경사항
|
||||
|
||||
### 카드 구조 변경 (한도관리형 → 리스크감지형)
|
||||
|
||||
| 섹션 | 기존 구현 | D1.7 기획서 |
|
||||
|------|---------|-----------|
|
||||
| **가지급금** | 카드, 가지급금, 법인세예상, 종합세예상 | 카드, 경조사, 상품권, 접대비, 총합계 (5카드) |
|
||||
| **접대비** | 매출, 분기한도, 잔여한도, 사용금액 | **주말/심야, 기피업종, 고액결제, 증빙미비** |
|
||||
| **복리후생비** | 당해한도, 분기한도, 잔여한도, 사용금액 | **비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과** |
|
||||
|
||||
### 신규 섹션 (2개)
|
||||
- 일별 매출 내역: 항목 설정(p47)에서 별도 ON/OFF
|
||||
- 일별 매입 내역: 항목 설정(p47)에서 별도 ON/OFF
|
||||
|
||||
### 설정 팝업 확장 (p45-47)
|
||||
- 접대비: 한도관리(연간/반기/분기/월), 기업구분(일반법인/중소기업), 고액결제기준금액
|
||||
- 복리후생비: 한도관리, 계산방식(직원당정액 or 연봉총액×비율), 조건부입력필드, 1회결제기준금액
|
||||
|
||||
---
|
||||
|
||||
## 4. 상세 모달 (10개)
|
||||
|
||||
| # | 모달 | 페이지 | 프론트 config | API 상태 |
|
||||
|---|------|--------|-------------|---------|
|
||||
| 1 | 일정 상세 | p44 | ✅ ScheduleDetailModal | ✅ 연동 |
|
||||
| 2 | 항목 설정 | p45-47 | ✅ DashboardSettingsDialog | localStorage |
|
||||
| 3 | 당월 매입 상세 | p48 | ✅ me1 config | ⚠️ 부분연동 |
|
||||
| 4 | 당월 카드 상세 | p49 | ✅ me2 config | ⚠️ 부분연동 |
|
||||
| 5 | 당월 발행어음 상세 | p50 | ✅ me3 config | ⚠️ 부분연동 |
|
||||
| 6 | 당월 지출 예상 상세 | p51 | ✅ me4 config | ⚠️ 부분연동 |
|
||||
| 7 | 가지급금 상세 | p52 | ✅ cm2 config | ⚠️ 구조변경 필요 |
|
||||
| 8 | 접대비 상세 | p53-54 | ✅ et config | ⚠️ 대폭확장 |
|
||||
| 9 | 복리후생비 상세 | p55-56 | ✅ wf config | ⚠️ 대폭확장 |
|
||||
| 10 | 예상 납부세액 상세 | p57 | ✅ vat config | ⚠️ 확장필요 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 필요 API 작업 (16개)
|
||||
|
||||
### 백엔드 API 수정 (6개)
|
||||
|
||||
| # | API | 변경 내용 |
|
||||
|---|-----|---------|
|
||||
| 1 | 가지급금 summary | 카드/경조사/상품권/접대비 분류 집계 |
|
||||
| 2 | 접대비 summary | 리스크 4종 (주말심야/기피업종/고액/증빙미비) - MCC코드 판별 |
|
||||
| 3 | 복리후생비 summary | 리스크 4종 (비과세초과/사적사용/편중/한도초과) |
|
||||
| 4 | 가지급금 detail | 분류별 상세 + AI분류 컬럼 |
|
||||
| 5 | 복리후생비 detail | 계산방식별 + 분기별현황 |
|
||||
| 6 | 부가세 detail | 신고기간별 + 부가세요약 + 미발행/미수취 |
|
||||
|
||||
### 백엔드 API 신규 (10개)
|
||||
|
||||
| # | API | 용도 | 난이도 |
|
||||
|---|-----|------|--------|
|
||||
| 1 | 접대비 detail | 한도계산 + 분기별현황 + 내역테이블 | 상 |
|
||||
| 2 | 매출 현황 summary | 누적/달성률/YoY/당월 + 차트 | 중 |
|
||||
| 3 | 일별 매출 내역 | 매출일, 거래처, 매출금액 | 하 |
|
||||
| 4 | 매입 현황 summary | 누적/미결제/YoY + 차트 | 중 |
|
||||
| 5 | 일별 매입 내역 | 매입일, 거래처, 매입금액 | 하 |
|
||||
| 6 | 생산 현황 | 공정별 집계 + 작업자실적 | 상 |
|
||||
| 7 | 출고 현황 | 7일/30일 예상출고 | 하 |
|
||||
| 8 | 미출고 내역 | 납기기준 미출고 조회 | 하 |
|
||||
| 9 | 시공 현황 | 진행/완료(7일이내) + 카드 | 중 |
|
||||
| 10 | 근태 현황 | 출근/휴가/지각/결근 집계 | 중 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 프론트엔드 작업 (8개)
|
||||
|
||||
| # | 작업 | 대상 |
|
||||
|---|------|------|
|
||||
| 1 | 가지급금 카드 구조 변경 | CardManagementSection |
|
||||
| 2 | 접대비 카드 → 리스크형 | EntertainmentSection |
|
||||
| 3 | 복리후생비 카드 → 리스크형 | WelfareSection |
|
||||
| 4 | 일별 매출 내역 섹션 신규 | 새 컴포넌트 |
|
||||
| 5 | 일별 매입 내역 섹션 신규 | 새 컴포넌트 |
|
||||
| 6 | 항목 설정 팝업 업데이트 | DashboardSettingsDialog |
|
||||
| 7 | 모달 config API 연동 | 각 modalConfigs |
|
||||
| 8 | Mock 섹션 API 연동 | 매출~근태 hook 생성 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 데이터 아키텍처
|
||||
|
||||
대시보드 전용 테이블 없음. 모든 데이터는 각 도메인 페이지 입력 데이터의 실시간 집계.
|
||||
|
||||
### 자금 현황 데이터 조합
|
||||
| 카드 | 출처 |
|
||||
|------|------|
|
||||
| 일일일보 | bank_accounts 잔액 합계 |
|
||||
| 미수금 잔액 | sales 합계 - deposits 합계 |
|
||||
| 미지급금 잔액 | purchases 합계 - payments 합계 |
|
||||
| 당월 예상 지출 | 매입예정 + 카드결제 + 어음만기 합산 |
|
||||
|
||||
### 리스크 감지 로직 (접대비/복리후생비)
|
||||
- MCC 코드 기반 업종 판별 (p60: 유흥업소, 귀금속, 골프장 등)
|
||||
- 체크 규칙: 시간대이상(22~06시), 업종이상, 금액이상(50만원), 빈도이상(월10회)
|
||||
- 사적사용 의심: 토요일 23시 + 유흥주점 + 25만원 → 2개 규칙 해당
|
||||
|
||||
### 캐싱
|
||||
- sam_stat 테이블 5분 캐시 (백엔드 기존 구현)
|
||||
|
||||
---
|
||||
|
||||
## 8. 참고 계산 공식 (p58-60)
|
||||
|
||||
### 가지급금 인정이자
|
||||
- 인정이자율: 4.6% (당좌대출이자율 기준, 매년 고시)
|
||||
- 인정이자 = 가지급금 × 일이자율(연이자율/365) × 경과일수
|
||||
- 법인세 추가: 인정이자 × 0.19
|
||||
- 대표자 소득세 추가: 인정이자 × 0.35
|
||||
|
||||
### 접대비 손금한도
|
||||
- 기본한도: 일반법인 1,200만원/년, 중소기업 3,600만원/년
|
||||
- 수입금액별 추가한도:
|
||||
- 100억 이하: 수입금액 × 0.2%
|
||||
- 100억~500억: 2,000만원 + (수입금액-100억) × 0.1%
|
||||
- 500억 초과: 6,000만원 + (수입금액-500억) × 0.03%
|
||||
|
||||
### 복리후생비 계산
|
||||
- 방식1 (직원당 정액): 직원수 × 월정액 × 12
|
||||
- 방식2 (연봉총액 비율): 연봉총액 × 비율%
|
||||
- 법정 복리후생비: 4대보험 회사부담분
|
||||
- 비과세 항목별 기준: 식대 20만원, 교통비 10만원, 경조사 5만원, 건강검진 월환산, 교육훈련 8만원, 복지포인트 10만원
|
||||
@@ -15,7 +15,6 @@ const eslintConfig = [
|
||||
"node_modules/**",
|
||||
"next-env.d.ts",
|
||||
"src/components/_unused/**", // Archived unused components
|
||||
"src/components/settings/AccountManagement/_legacy/**", // Legacy files
|
||||
"src/hooks/useCurrentTime.ts", // Demo hook
|
||||
],
|
||||
},
|
||||
@@ -77,38 +76,9 @@ const eslintConfig = [
|
||||
HTMLTableCaptionElement: "readonly",
|
||||
HTMLTextAreaElement: "readonly",
|
||||
HTMLCanvasElement: "readonly",
|
||||
HTMLDivElement: "readonly",
|
||||
HTMLElement: "readonly",
|
||||
HTMLImageElement: "readonly",
|
||||
ImageData: "readonly",
|
||||
Image: "readonly",
|
||||
prompt: "readonly",
|
||||
Audio: "readonly",
|
||||
Blob: "readonly",
|
||||
CSSStyleDeclaration: "readonly",
|
||||
CustomEvent: "readonly",
|
||||
Element: "readonly",
|
||||
ErrorEvent: "readonly",
|
||||
Event: "readonly",
|
||||
FileList: "readonly",
|
||||
FileReader: "readonly",
|
||||
Headers: "readonly",
|
||||
IntersectionObserver: "readonly",
|
||||
KeyboardEvent: "readonly",
|
||||
MouseEvent: "readonly",
|
||||
Node: "readonly",
|
||||
NodeJS: "readonly",
|
||||
PromiseRejectionEvent: "readonly",
|
||||
RequestCache: "readonly",
|
||||
ResizeObserver: "readonly",
|
||||
Storage: "readonly",
|
||||
cancelAnimationFrame: "readonly",
|
||||
crypto: "readonly",
|
||||
getComputedStyle: "readonly",
|
||||
google: "readonly",
|
||||
navigator: "readonly",
|
||||
requestAnimationFrame: "readonly",
|
||||
sessionStorage: "readonly",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
@@ -125,9 +95,7 @@ const eslintConfig = [
|
||||
"@typescript-eslint/no-explicit-any": "warn",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_"
|
||||
"varsIgnorePattern": "^_"
|
||||
}],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -6,7 +6,6 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
||||
const nextConfig: NextConfig = {
|
||||
reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
|
||||
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
||||
allowedDevOrigins: ['192.168.0.*'], // 로컬 네트워크 기기 접속 허용
|
||||
serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외
|
||||
images: {
|
||||
remotePatterns: [
|
||||
@@ -29,9 +28,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
// Capacitor 패키지는 모바일 앱 전용 - 웹 빌드에서 제외
|
||||
webpack: (config, { isServer }) => {
|
||||
// macOS 26 호환성: webpack 캐시 비활성화 (rename ENOENT 방지)
|
||||
config.cache = false;
|
||||
|
||||
if (!isServer) {
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node scripts/validate-next-cache.mjs && NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' next dev",
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
|
||||
"start": "next start -H 0.0.0.0",
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
/**
|
||||
* JSON.parse 글로벌 패치 - macOS 26 파일시스템 손상 대응
|
||||
*
|
||||
* macOS 26에서 atomic write(tmp + rename)가 실패하면
|
||||
* .next/prerender-manifest.json 등의 파일에 데이터가 중복 기록됨.
|
||||
* 이로 인해 "Unexpected non-whitespace character after JSON at position N" 발생.
|
||||
*
|
||||
* 이 패치는 JSON.parse 실패 시 유효한 JSON 부분만 추출하여 자동 복구.
|
||||
* NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' 로 로드.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const originalParse = JSON.parse;
|
||||
|
||||
JSON.parse = function patchedJsonParse(text, reviver) {
|
||||
try {
|
||||
return originalParse.call(this, text, reviver);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError && typeof text === 'string') {
|
||||
// "Unexpected non-whitespace character after JSON at position N"
|
||||
// → position N까지가 유효한 JSON
|
||||
const match = e.message.match(/after JSON at position\s+(\d+)/);
|
||||
if (match) {
|
||||
const pos = parseInt(match[1], 10);
|
||||
if (pos > 0) {
|
||||
try {
|
||||
const result = originalParse.call(this, text.substring(0, pos), reviver);
|
||||
// 한 번만 경고 (같은 position이면 반복 출력 방지)
|
||||
if (!patchedJsonParse._warned) patchedJsonParse._warned = new Set();
|
||||
const key = pos + ':' + text.length;
|
||||
if (!patchedJsonParse._warned.has(key)) {
|
||||
patchedJsonParse._warned.add(key);
|
||||
console.warn(
|
||||
`[patch-json-parse] macOS 파일 손상 자동 복구 (position ${pos}, total ${text.length} bytes)`
|
||||
);
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
// truncation으로도 실패하면 원래 에러 throw
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* .next 빌드 캐시 무결성 검증
|
||||
*
|
||||
* macOS 26 파일시스템 이슈로 .next/ 내 JSON 파일이 손상될 수 있음.
|
||||
* (atomic write 실패 → 데이터 중복 기록)
|
||||
* dev 서버 시작 전 자동 검증하여 손상 시 .next 삭제.
|
||||
*/
|
||||
import { readFileSync, rmSync, existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const NEXT_DIR = '.next';
|
||||
|
||||
if (!existsSync(NEXT_DIR)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const jsonFiles = [];
|
||||
try {
|
||||
// .next/ 루트의 JSON 파일들
|
||||
for (const f of readdirSync(NEXT_DIR)) {
|
||||
if (f.endsWith('.json')) jsonFiles.push(join(NEXT_DIR, f));
|
||||
}
|
||||
// .next/server/ 의 JSON 파일들
|
||||
const serverDir = join(NEXT_DIR, 'server');
|
||||
if (existsSync(serverDir)) {
|
||||
for (const f of readdirSync(serverDir)) {
|
||||
if (f.endsWith('.json')) jsonFiles.push(join(serverDir, f));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 디렉토리 읽기 실패 시 무시
|
||||
}
|
||||
|
||||
let corrupted = false;
|
||||
for (const file of jsonFiles) {
|
||||
try {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
JSON.parse(content);
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ 손상된 캐시 발견: ${file}`);
|
||||
console.warn(` ${e.message}`);
|
||||
corrupted = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (corrupted) {
|
||||
console.warn('🗑️ .next 캐시를 삭제하고 재빌드합니다...');
|
||||
rmSync(NEXT_DIR, { recursive: true, force: true });
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export default function VendorsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'new') {
|
||||
getClients({ size: 1000 })
|
||||
getClients({ size: 100 })
|
||||
.then(result => {
|
||||
setData(result.data);
|
||||
setTotal(result.total);
|
||||
|
||||
@@ -103,7 +103,7 @@ function BoardListContent({ boardCode }: { boardCode: string }) {
|
||||
|
||||
// 게시글 목록
|
||||
const [posts, setPosts] = useState<BoardPost[]>([]);
|
||||
const [, setIsLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 필터 및 검색
|
||||
@@ -239,11 +239,11 @@ function BoardListContent({ boardCode }: { boardCode: string }) {
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'title', label: '제목', className: 'min-w-[200px]', copyable: true },
|
||||
{ key: 'author', label: '작성자', className: 'w-[120px]', copyable: true },
|
||||
{ key: 'views', label: '조회수', className: 'w-[80px] text-center', copyable: true },
|
||||
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
|
||||
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
||||
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
|
||||
], []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
deleteDynamicBoardPost,
|
||||
} from '@/components/board/DynamicBoard/actions';
|
||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||
import { transformApiToComment } from '@/components/customer-center/shared/types';
|
||||
import { transformApiToComment, type CommentApiData } from '@/components/customer-center/shared/types';
|
||||
import type { PostApiData } from '@/components/customer-center/shared/types';
|
||||
import { sanitizeHTML } from '@/lib/sanitize';
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) {
|
||||
|
||||
// 게시글 목록
|
||||
const [posts, setPosts] = useState<BoardPost[]>([]);
|
||||
const [, setIsLoading] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 필터 및 검색
|
||||
@@ -246,11 +246,11 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) {
|
||||
// 테이블 컬럼
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'title', label: '제목', className: 'min-w-[200px]', copyable: true },
|
||||
{ key: 'author', label: '작성자', className: 'w-[120px]', copyable: true },
|
||||
{ key: 'views', label: '조회수', className: 'w-[80px] text-center', copyable: true },
|
||||
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
|
||||
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
||||
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true },
|
||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
|
||||
], []);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'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);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { EditableTable, EditableColumn } from '@/components/common/EditableTable';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
|
||||
@@ -75,7 +75,6 @@ export default function AttendancePage() {
|
||||
|
||||
setSiteLocation(finalLocation);
|
||||
} else {
|
||||
// no fallback location needed
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AttendancePage] loadSettings error:', error);
|
||||
|
||||
@@ -11,17 +11,23 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { useState, useMemo, Suspense } from 'react';
|
||||
import { FileText, ArrowLeft, Calendar, Clock, MapPin, FileCheck } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo, Suspense } from 'react';
|
||||
import { FileText, ArrowLeft, Calendar, User, Clock, MapPin, FileCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { FormSectionSkeleton } from '@/components/ui/skeleton';
|
||||
import { format } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// 문서 유형 라벨
|
||||
|
||||
@@ -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: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
|
||||
// ===== API 원본 타입 (snake_case) =====
|
||||
// ⚠️ 'use server' 파일에서 타입 re-export 금지 (Turbopack 제한)
|
||||
|
||||
interface QualityReportApi {
|
||||
id: number;
|
||||
code: string;
|
||||
site_name: string;
|
||||
item: string;
|
||||
route_count: number;
|
||||
total_routes: number;
|
||||
quarter: string;
|
||||
year: number;
|
||||
quarter_num: number;
|
||||
}
|
||||
|
||||
interface RouteItemApi {
|
||||
id: number;
|
||||
code: string;
|
||||
date: string;
|
||||
client: string;
|
||||
site: string;
|
||||
location_count: number;
|
||||
sub_items: {
|
||||
id: number;
|
||||
name: string;
|
||||
location: string;
|
||||
is_completed: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
interface DocumentApi {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
date?: string;
|
||||
count: number;
|
||||
items?: {
|
||||
id: number;
|
||||
title: string;
|
||||
date: string;
|
||||
code?: string;
|
||||
sub_type?: string;
|
||||
work_order_id?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
// ===== Transform 함수 (snake_case → camelCase) =====
|
||||
|
||||
function transformReportApi(api: QualityReportApi) {
|
||||
return {
|
||||
id: String(api.id),
|
||||
code: api.code,
|
||||
siteName: api.site_name,
|
||||
item: api.item,
|
||||
routeCount: api.route_count,
|
||||
totalRoutes: api.total_routes,
|
||||
quarter: api.quarter,
|
||||
year: api.year,
|
||||
quarterNum: api.quarter_num,
|
||||
};
|
||||
}
|
||||
|
||||
function transformRouteApi(api: RouteItemApi) {
|
||||
return {
|
||||
id: String(api.id),
|
||||
code: api.code,
|
||||
date: api.date,
|
||||
client: api.client,
|
||||
site: api.site,
|
||||
locationCount: api.location_count,
|
||||
subItems: api.sub_items.map((s) => ({
|
||||
id: String(s.id),
|
||||
name: s.name,
|
||||
location: s.location,
|
||||
isCompleted: s.is_completed,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function transformDocumentApi(api: DocumentApi) {
|
||||
return {
|
||||
id: String(api.id),
|
||||
type: api.type as 'import' | 'order' | 'log' | 'report' | 'confirmation' | 'shipping' | 'product' | 'quality',
|
||||
title: api.title,
|
||||
date: api.date,
|
||||
count: api.count,
|
||||
items: api.items?.map((i) => ({
|
||||
id: String(i.id),
|
||||
title: i.title,
|
||||
date: i.date,
|
||||
code: i.code,
|
||||
subType: i.sub_type as 'screen' | 'bending' | 'slat' | 'jointbar' | undefined,
|
||||
workOrderId: i.work_order_id,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 2일차: 로트 추적 심사 =====
|
||||
|
||||
export async function getQualityReports(params: {
|
||||
year: number;
|
||||
quarter?: number;
|
||||
q?: string;
|
||||
}) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/qms/lot-audit/reports', {
|
||||
year: params.year,
|
||||
quarter: params.quarter,
|
||||
q: params.q,
|
||||
}),
|
||||
transform: (data: { items: QualityReportApi[] }) =>
|
||||
data.items.map(transformReportApi),
|
||||
errorMessage: '품질관리서 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getReportRoutes(reportId: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/lot-audit/reports/${reportId}`),
|
||||
transform: (data: RouteItemApi[]) => data.map(transformRouteApi),
|
||||
errorMessage: '수주/개소 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRouteDocuments(routeId: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/lot-audit/routes/${routeId}/documents`),
|
||||
transform: (data: DocumentApi[]) => data.map(transformDocumentApi),
|
||||
errorMessage: '서류 목록 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDocumentDetail(type: string, id: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/lot-audit/documents/${type}/${id}`),
|
||||
errorMessage: '서류 상세 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmUnitInspection(unitId: string, confirmed: boolean) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/qms/lot-audit/units/${unitId}/confirm`),
|
||||
method: 'PATCH',
|
||||
body: { confirmed },
|
||||
transform: (data: { id: number; name: string; location: string; is_completed: boolean }) => ({
|
||||
id: String(data.id),
|
||||
name: data.name,
|
||||
location: data.location,
|
||||
isCompleted: data.is_completed,
|
||||
}),
|
||||
errorMessage: '확인 상태 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 1일차: 점검표 항목 토글 =====
|
||||
|
||||
export async function toggleTemplateItem(templateId: number, subItemId: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/quality/checklist-templates/${templateId}/items/${subItemId}/toggle`),
|
||||
method: 'PATCH',
|
||||
transform: (data: { id: string; name: string; is_completed: boolean; completed_at?: string }) => ({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
isCompleted: data.is_completed,
|
||||
}),
|
||||
errorMessage: '항목 상태 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 점검표 템플릿 관리 (설정 모달) =====
|
||||
|
||||
interface ChecklistTemplateApi {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
categories: {
|
||||
id: string;
|
||||
title: string;
|
||||
subItems: { id: string; name: string; is_completed?: boolean }[];
|
||||
}[];
|
||||
options: Record<string, unknown> | null;
|
||||
file_counts: Record<string, number>;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
interface TemplateDocumentApi {
|
||||
id: number;
|
||||
field_key: string;
|
||||
display_name: string;
|
||||
file_size: number;
|
||||
mime_type: string;
|
||||
uploaded_by: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export async function getChecklistTemplate(type: string = 'day1_audit') {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/quality/checklist-templates', { type }),
|
||||
transform: (data: ChecklistTemplateApi) => ({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
categories: data.categories.map((cat) => ({
|
||||
id: cat.id,
|
||||
title: cat.title,
|
||||
subItems: cat.subItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
isCompleted: item.is_completed ?? false,
|
||||
})),
|
||||
})),
|
||||
options: data.options,
|
||||
fileCounts: data.file_counts,
|
||||
updatedAt: data.updated_at,
|
||||
updatedBy: data.updated_by,
|
||||
}),
|
||||
errorMessage: '점검표 템플릿 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveChecklistTemplate(
|
||||
id: number,
|
||||
data: { name?: string; categories: { id: string; title: string; subItems: { id: string; name: string }[] }[]; options?: Record<string, unknown> },
|
||||
) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/quality/checklist-templates/${id}`),
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
transform: (result: ChecklistTemplateApi) => ({
|
||||
id: result.id,
|
||||
name: result.name,
|
||||
type: result.type,
|
||||
categories: result.categories.map((cat) => ({
|
||||
id: cat.id,
|
||||
title: cat.title,
|
||||
subItems: cat.subItems.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
isCompleted: item.is_completed ?? false,
|
||||
})),
|
||||
})),
|
||||
options: result.options,
|
||||
fileCounts: result.file_counts,
|
||||
updatedAt: result.updated_at,
|
||||
updatedBy: result.updated_by,
|
||||
}),
|
||||
errorMessage: '점검표 템플릿 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function getTemplateDocuments(templateId: number, subItemId?: string) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/quality/qms-documents', {
|
||||
template_id: templateId,
|
||||
sub_item_id: subItemId,
|
||||
}),
|
||||
transform: (data: TemplateDocumentApi[]) =>
|
||||
data.map((d) => ({
|
||||
id: d.id,
|
||||
fieldKey: d.field_key,
|
||||
displayName: d.display_name,
|
||||
fileSize: d.file_size,
|
||||
mimeType: d.mime_type,
|
||||
uploadedBy: d.uploaded_by,
|
||||
createdAt: d.created_at,
|
||||
})),
|
||||
errorMessage: '템플릿 문서 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadTemplateDocument(templateId: number, subItemId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('template_id', String(templateId));
|
||||
formData.append('sub_item_id', subItemId);
|
||||
formData.append('file', file);
|
||||
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/quality/qms-documents'),
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
transform: (d: TemplateDocumentApi) => ({
|
||||
id: d.id,
|
||||
fieldKey: d.field_key,
|
||||
displayName: d.display_name,
|
||||
fileSize: d.file_size,
|
||||
mimeType: d.mime_type,
|
||||
createdAt: d.created_at,
|
||||
}),
|
||||
errorMessage: '파일 업로드에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteTemplateDocument(fileId: number, replace: boolean = false) {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/quality/qms-documents/${fileId}`, {
|
||||
replace: replace ? 'true' : undefined,
|
||||
}),
|
||||
method: 'DELETE',
|
||||
errorMessage: '파일 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Settings, X, Eye, EyeOff, ListChecks } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import React from 'react';
|
||||
import { Settings, X, Eye, EyeOff } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ChecklistTemplateEditor } from './ChecklistTemplateEditor';
|
||||
import type { ChecklistCategory } from '../types';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
export interface AuditDisplaySettings {
|
||||
showProgressBar: boolean;
|
||||
@@ -15,46 +13,19 @@ export interface AuditDisplaySettings {
|
||||
expandAllCategories: boolean;
|
||||
}
|
||||
|
||||
// 점검표 관리 props
|
||||
export interface ChecklistManagementProps {
|
||||
categories: ChecklistCategory[];
|
||||
hasChanges: boolean;
|
||||
saving: boolean;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
onAddCategory: () => void;
|
||||
onUpdateCategoryTitle: (categoryId: string, title: string) => void;
|
||||
onDeleteCategory: (categoryId: string) => void;
|
||||
onMoveCategoryUp: (index: number) => void;
|
||||
onMoveCategoryDown: (index: number) => void;
|
||||
onAddSubItem: (categoryId: string) => void;
|
||||
onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void;
|
||||
onDeleteSubItem: (categoryId: string, subItemId: string) => void;
|
||||
onMoveSubItemUp: (categoryId: string, index: number) => void;
|
||||
onMoveSubItemDown: (categoryId: string, index: number) => void;
|
||||
onSave: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
interface AuditSettingsPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
settings: AuditDisplaySettings;
|
||||
onSettingsChange: (settings: AuditDisplaySettings) => void;
|
||||
checklistManagement?: ChecklistManagementProps;
|
||||
}
|
||||
|
||||
type TabType = 'display' | 'checklist';
|
||||
|
||||
export function AuditSettingsPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
checklistManagement,
|
||||
}: AuditSettingsPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('display');
|
||||
|
||||
const handleToggle = (key: keyof AuditDisplaySettings) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
@@ -78,7 +49,7 @@ export function AuditSettingsPanel({
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-gray-600" />
|
||||
<h3 className="font-semibold text-gray-900">설정</h3>
|
||||
<h3 className="font-semibold text-gray-900">화면 설정</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@@ -89,183 +60,103 @@ export function AuditSettingsPanel({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('display')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
|
||||
activeTab === 'display'
|
||||
? 'text-blue-600 border-b-2 border-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
화면 설정
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('checklist')}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
|
||||
activeTab === 'checklist'
|
||||
? 'text-blue-600 border-b-2 border-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
)}
|
||||
>
|
||||
<ListChecks className="h-3.5 w-3.5" />
|
||||
점검표 관리
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'display' ? (
|
||||
<DisplaySettingsContent
|
||||
settings={settings}
|
||||
onToggle={handleToggle}
|
||||
onSettingsChange={onSettingsChange}
|
||||
/>
|
||||
) : checklistManagement ? (
|
||||
<ChecklistTemplateEditor
|
||||
categories={checklistManagement.categories}
|
||||
hasChanges={checklistManagement.hasChanges}
|
||||
saving={checklistManagement.saving}
|
||||
loading={checklistManagement.loading}
|
||||
error={checklistManagement.error}
|
||||
onAddCategory={checklistManagement.onAddCategory}
|
||||
onUpdateCategoryTitle={checklistManagement.onUpdateCategoryTitle}
|
||||
onDeleteCategory={checklistManagement.onDeleteCategory}
|
||||
onMoveCategoryUp={checklistManagement.onMoveCategoryUp}
|
||||
onMoveCategoryDown={checklistManagement.onMoveCategoryDown}
|
||||
onAddSubItem={checklistManagement.onAddSubItem}
|
||||
onUpdateSubItemName={checklistManagement.onUpdateSubItemName}
|
||||
onDeleteSubItem={checklistManagement.onDeleteSubItem}
|
||||
onMoveSubItemUp={checklistManagement.onMoveSubItemUp}
|
||||
onMoveSubItemDown={checklistManagement.onMoveSubItemDown}
|
||||
onSave={checklistManagement.onSave}
|
||||
onReset={checklistManagement.onReset}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||
점검표 관리 데이터를 불러오는 중...
|
||||
{/* 설정 항목 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{/* 레이아웃 섹션 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">레이아웃</h4>
|
||||
<div className="space-y-3">
|
||||
<SettingRow
|
||||
label="진행률 표시"
|
||||
description="상단 전체 심사 진행률 바를 표시합니다"
|
||||
checked={settings.showProgressBar}
|
||||
onChange={() => handleToggle('showProgressBar')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="문서 뷰어"
|
||||
description="우측 문서 미리보기 패널을 표시합니다"
|
||||
checked={settings.showDocumentViewer}
|
||||
onChange={() => handleToggle('showDocumentViewer')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="기준 문서화 섹션"
|
||||
description="중앙 기준 문서 목록 패널을 표시합니다"
|
||||
checked={settings.showDocumentSection}
|
||||
onChange={() => handleToggle('showDocumentSection')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 안내 (화면 설정 탭일 때만) */}
|
||||
{activeTab === 'display' && (
|
||||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-xs text-gray-500">
|
||||
설정은 자동으로 저장됩니다
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 화면 설정 탭 컨텐츠 (기존 코드 분리) =====
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-200" />
|
||||
|
||||
interface DisplaySettingsContentProps {
|
||||
settings: AuditDisplaySettings;
|
||||
onToggle: (key: keyof AuditDisplaySettings) => void;
|
||||
onSettingsChange: (settings: AuditDisplaySettings) => void;
|
||||
}
|
||||
{/* 점검표 섹션 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">점검표 옵션</h4>
|
||||
<div className="space-y-3">
|
||||
<SettingRow
|
||||
label="완료된 항목 표시"
|
||||
description="완료된 점검 항목을 목록에 표시합니다"
|
||||
checked={settings.showCompletedItems}
|
||||
onChange={() => handleToggle('showCompletedItems')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="모든 카테고리 펼치기"
|
||||
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
|
||||
checked={settings.expandAllCategories}
|
||||
onChange={() => handleToggle('expandAllCategories')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
function DisplaySettingsContent({ settings, onToggle, onSettingsChange }: DisplaySettingsContentProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 레이아웃 섹션 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">레이아웃</h4>
|
||||
<div className="space-y-3">
|
||||
<SettingRow
|
||||
label="진행률 표시"
|
||||
description="상단 전체 심사 진행률 바를 표시합니다"
|
||||
checked={settings.showProgressBar}
|
||||
onChange={() => onToggle('showProgressBar')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="문서 뷰어"
|
||||
description="우측 문서 미리보기 패널을 표시합니다"
|
||||
checked={settings.showDocumentViewer}
|
||||
onChange={() => onToggle('showDocumentViewer')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="기준 문서화 섹션"
|
||||
description="중앙 기준 문서 목록 패널을 표시합니다"
|
||||
checked={settings.showDocumentSection}
|
||||
onChange={() => onToggle('showDocumentSection')}
|
||||
/>
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-200" />
|
||||
|
||||
{/* 빠른 설정 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">빠른 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettingsChange({
|
||||
showProgressBar: true,
|
||||
showDocumentViewer: true,
|
||||
showDocumentSection: true,
|
||||
showCompletedItems: true,
|
||||
expandAllCategories: true,
|
||||
})}
|
||||
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
모두 표시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettingsChange({
|
||||
showProgressBar: false,
|
||||
showDocumentViewer: false,
|
||||
showDocumentSection: true,
|
||||
showCompletedItems: true,
|
||||
expandAllCategories: false,
|
||||
})}
|
||||
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
간소화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-200" />
|
||||
|
||||
{/* 점검표 섹션 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">점검표 옵션</h4>
|
||||
<div className="space-y-3">
|
||||
<SettingRow
|
||||
label="완료된 항목 표시"
|
||||
description="완료된 점검 항목을 목록에 표시합니다"
|
||||
checked={settings.showCompletedItems}
|
||||
onChange={() => onToggle('showCompletedItems')}
|
||||
/>
|
||||
<SettingRow
|
||||
label="모든 카테고리 펼치기"
|
||||
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
|
||||
checked={settings.expandAllCategories}
|
||||
onChange={() => onToggle('expandAllCategories')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 구분선 */}
|
||||
<div className="border-t border-gray-200" />
|
||||
|
||||
{/* 빠른 설정 */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-3">빠른 설정</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettingsChange({
|
||||
showProgressBar: true,
|
||||
showDocumentViewer: true,
|
||||
showDocumentSection: true,
|
||||
showCompletedItems: true,
|
||||
expandAllCategories: true,
|
||||
})}
|
||||
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
모두 표시
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSettingsChange({
|
||||
showProgressBar: false,
|
||||
showDocumentViewer: false,
|
||||
showDocumentSection: true,
|
||||
showCompletedItems: true,
|
||||
expandAllCategories: false,
|
||||
})}
|
||||
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
간소화
|
||||
</button>
|
||||
{/* 하단 안내 */}
|
||||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-xs text-gray-500">
|
||||
설정은 자동으로 저장됩니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 공통 설정 행 =====
|
||||
|
||||
interface SettingRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
@@ -312,4 +203,4 @@ export function SettingsButton({ onClick }: SettingsButtonProps) {
|
||||
<span>화면 설정</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,449 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Plus,
|
||||
Check,
|
||||
X,
|
||||
ChevronRight,
|
||||
Save,
|
||||
RotateCcw,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ChecklistCategory, ChecklistSubItem } from '../types';
|
||||
|
||||
interface ChecklistTemplateEditorProps {
|
||||
categories: ChecklistCategory[];
|
||||
hasChanges: boolean;
|
||||
saving: boolean;
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
// 카테고리
|
||||
onAddCategory: () => void;
|
||||
onUpdateCategoryTitle: (categoryId: string, title: string) => void;
|
||||
onDeleteCategory: (categoryId: string) => void;
|
||||
onMoveCategoryUp: (index: number) => void;
|
||||
onMoveCategoryDown: (index: number) => void;
|
||||
// 하위 항목
|
||||
onAddSubItem: (categoryId: string) => void;
|
||||
onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void;
|
||||
onDeleteSubItem: (categoryId: string, subItemId: string) => void;
|
||||
onMoveSubItemUp: (categoryId: string, index: number) => void;
|
||||
onMoveSubItemDown: (categoryId: string, index: number) => void;
|
||||
// 저장/초기화
|
||||
onSave: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export function ChecklistTemplateEditor({
|
||||
categories,
|
||||
hasChanges,
|
||||
saving,
|
||||
loading,
|
||||
error,
|
||||
onAddCategory,
|
||||
onUpdateCategoryTitle,
|
||||
onDeleteCategory,
|
||||
onMoveCategoryUp,
|
||||
onMoveCategoryDown,
|
||||
onAddSubItem,
|
||||
onUpdateSubItemName,
|
||||
onDeleteSubItem,
|
||||
onMoveSubItemUp,
|
||||
onMoveSubItemDown,
|
||||
onSave,
|
||||
onReset,
|
||||
}: ChecklistTemplateEditorProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(categories.map(c => c.id))
|
||||
);
|
||||
|
||||
const toggleExpand = (categoryId: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(categoryId)) next.delete(categoryId);
|
||||
else next.add(categoryId);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
점검표 로딩 중...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32 text-red-500 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 카테고리 목록 */}
|
||||
<div className="space-y-1.5">
|
||||
{categories.map((category, catIdx) => (
|
||||
<CategoryEditor
|
||||
key={category.id}
|
||||
category={category}
|
||||
index={catIdx}
|
||||
isFirst={catIdx === 0}
|
||||
isLast={catIdx === categories.length - 1}
|
||||
isExpanded={expandedCategories.has(category.id)}
|
||||
onToggleExpand={() => toggleExpand(category.id)}
|
||||
onUpdateTitle={(title) => onUpdateCategoryTitle(category.id, title)}
|
||||
onDelete={() => onDeleteCategory(category.id)}
|
||||
onMoveUp={() => onMoveCategoryUp(catIdx)}
|
||||
onMoveDown={() => onMoveCategoryDown(catIdx)}
|
||||
onAddSubItem={() => onAddSubItem(category.id)}
|
||||
onUpdateSubItemName={(subItemId, name) => onUpdateSubItemName(category.id, subItemId, name)}
|
||||
onDeleteSubItem={(subItemId) => onDeleteSubItem(category.id, subItemId)}
|
||||
onMoveSubItemUp={(idx) => onMoveSubItemUp(category.id, idx)}
|
||||
onMoveSubItemDown={(idx) => onMoveSubItemDown(category.id, idx)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 카테고리 추가 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddCategory}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs text-blue-600 bg-blue-50 rounded-lg border border-dashed border-blue-200 hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
카테고리 추가
|
||||
</button>
|
||||
|
||||
{/* 저장/초기화 */}
|
||||
<div className="flex items-center gap-2 pt-2 border-t border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
disabled={!hasChanges}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
초기화
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSave()}
|
||||
disabled={!hasChanges || saving}
|
||||
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<p className="text-[10px] text-amber-600 text-center">
|
||||
저장하지 않은 변경사항이 있습니다
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 카테고리 편집 =====
|
||||
|
||||
interface CategoryEditorProps {
|
||||
category: ChecklistCategory;
|
||||
index: number;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
isExpanded: boolean;
|
||||
onToggleExpand: () => void;
|
||||
onUpdateTitle: (title: string) => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onAddSubItem: () => void;
|
||||
onUpdateSubItemName: (subItemId: string, name: string) => void;
|
||||
onDeleteSubItem: (subItemId: string) => void;
|
||||
onMoveSubItemUp: (index: number) => void;
|
||||
onMoveSubItemDown: (index: number) => void;
|
||||
}
|
||||
|
||||
function CategoryEditor({
|
||||
category,
|
||||
index,
|
||||
isFirst,
|
||||
isLast,
|
||||
isExpanded,
|
||||
onToggleExpand,
|
||||
onUpdateTitle,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onAddSubItem,
|
||||
onUpdateSubItemName,
|
||||
onDeleteSubItem,
|
||||
onMoveSubItemUp,
|
||||
onMoveSubItemDown,
|
||||
}: CategoryEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(category.title);
|
||||
|
||||
const handleSaveTitle = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed) {
|
||||
onUpdateTitle(trimmed);
|
||||
} else {
|
||||
setEditValue(category.title);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSaveTitle();
|
||||
if (e.key === 'Escape') {
|
||||
setEditValue(category.title);
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
|
||||
{/* 카테고리 헤더 */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5">
|
||||
{/* 순서 변경 */}
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 펼치기/접기 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleExpand}
|
||||
className="p-0.5 text-gray-500"
|
||||
>
|
||||
<ChevronRight className={cn(
|
||||
'h-3.5 w-3.5 transition-transform',
|
||||
isExpanded && 'rotate-90'
|
||||
)} />
|
||||
</button>
|
||||
|
||||
{/* 제목 */}
|
||||
{editing ? (
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleSaveTitle}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 text-xs px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" onClick={handleSaveTitle} className="p-0.5 text-green-600">
|
||||
<Check className="h-3 w-3" />
|
||||
</button>
|
||||
<button type="button" onClick={() => { setEditValue(category.title); setEditing(false); }} className="p-0.5 text-gray-400">
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span className="flex-1 text-xs font-medium text-gray-800 truncate">
|
||||
{index + 1}. {category.title}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 항목 수 */}
|
||||
<span className="text-[10px] text-gray-400 mr-1">
|
||||
{category.subItems.length}
|
||||
</span>
|
||||
|
||||
{/* 편집/삭제 */}
|
||||
{!editing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditValue(category.title); setEditing(true); }}
|
||||
className="p-1 text-gray-400 hover:text-blue-600 rounded"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="p-1 text-gray-400 hover:text-red-600 rounded"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하위 항목 */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-200 bg-white">
|
||||
{category.subItems.map((subItem, subIdx) => (
|
||||
<SubItemEditor
|
||||
key={subItem.id}
|
||||
subItem={subItem}
|
||||
index={subIdx}
|
||||
isFirst={subIdx === 0}
|
||||
isLast={subIdx === category.subItems.length - 1}
|
||||
onUpdateName={(name) => onUpdateSubItemName(subItem.id, name)}
|
||||
onDelete={() => onDeleteSubItem(subItem.id)}
|
||||
onMoveUp={() => onMoveSubItemUp(subIdx)}
|
||||
onMoveDown={() => onMoveSubItemDown(subIdx)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 항목 추가 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddSubItem}
|
||||
className="w-full flex items-center justify-center gap-1 py-1.5 text-[10px] text-blue-500 hover:bg-blue-50 transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
항목 추가
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 하위 항목 편집 =====
|
||||
|
||||
interface SubItemEditorProps {
|
||||
subItem: ChecklistSubItem;
|
||||
index: number;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onUpdateName: (name: string) => void;
|
||||
onDelete: () => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
}
|
||||
|
||||
function SubItemEditor({
|
||||
subItem,
|
||||
index,
|
||||
isFirst,
|
||||
isLast,
|
||||
onUpdateName,
|
||||
onDelete,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
}: SubItemEditorProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(subItem.name);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed) {
|
||||
onUpdateName(trimmed);
|
||||
} else {
|
||||
setEditValue(subItem.name);
|
||||
}
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') {
|
||||
setEditValue(subItem.name);
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
|
||||
{/* 순서 변경 */}
|
||||
<div className="flex flex-col ml-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronUp className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
<ChevronDown className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 이름 */}
|
||||
{editing ? (
|
||||
<div className="flex-1 flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onBlur={handleSave}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="flex-1 text-[11px] px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||
autoFocus
|
||||
/>
|
||||
<button type="button" onClick={handleSave} className="p-0.5 text-green-600">
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className="flex-1 text-[11px] text-gray-700 cursor-pointer hover:text-blue-600 truncate"
|
||||
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
|
||||
>
|
||||
{subItem.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* 편집/삭제 */}
|
||||
{!editing && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
|
||||
className="p-0.5 text-gray-300 hover:text-blue-500"
|
||||
>
|
||||
<Pencil className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="p-0.5 text-gray-300 hover:text-red-500"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ interface Day1ChecklistPanelProps {
|
||||
searchTerm: string;
|
||||
onSubItemSelect: (categoryId: string, subItemId: string) => void;
|
||||
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export function Day1ChecklistPanel({
|
||||
@@ -20,7 +19,6 @@ export function Day1ChecklistPanel({
|
||||
searchTerm,
|
||||
onSubItemSelect,
|
||||
onSubItemToggle,
|
||||
isMock,
|
||||
}: Day1ChecklistPanelProps) {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
|
||||
@@ -50,13 +48,6 @@ export function Day1ChecklistPanel({
|
||||
}).filter((cat): cat is ChecklistCategory => cat !== null);
|
||||
}, [categories, searchTerm]);
|
||||
|
||||
// categories 로드 완료 시 모두 펼치기
|
||||
React.useEffect(() => {
|
||||
if (categories.length > 0) {
|
||||
setExpandedCategories(new Set(categories.map(c => c.id)));
|
||||
}
|
||||
}, [categories]);
|
||||
|
||||
// 검색 시 모든 카테고리 펼치기
|
||||
React.useEffect(() => {
|
||||
if (searchTerm.trim()) {
|
||||
@@ -104,14 +95,7 @@ export function Day1ChecklistPanel({
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
||||
{/* 검색 결과 카운트 */}
|
||||
{searchTerm && (
|
||||
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
|
||||
@@ -130,7 +114,7 @@ export function Day1ChecklistPanel({
|
||||
검색 결과가 없습니다
|
||||
</div>
|
||||
) : (
|
||||
filteredCategories.map((category, _categoryIndex) => {
|
||||
filteredCategories.map((category, categoryIndex) => {
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
const progress = getCategoryProgress(category);
|
||||
const allCompleted = progress.completed === progress.total;
|
||||
|
||||
@@ -1,45 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { FileText, CheckCircle2, Upload, X, Loader2, Trash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { FileText, Download, Eye, CheckCircle2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import type { Day1CheckItem, TemplateDocument } from '../types';
|
||||
|
||||
const ACCEPTED_EXTENSIONS = '.pdf,.xlsx,.xls,.doc,.docx,.hwp';
|
||||
const ACCEPTED_MIME = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-excel',
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/haansofthwp',
|
||||
];
|
||||
const MAX_FILE_SIZE_MB = 20;
|
||||
import type { Day1CheckItem, StandardDocument } from '../types';
|
||||
|
||||
interface Day1DocumentSectionProps {
|
||||
checkItem: Day1CheckItem | null;
|
||||
selectedDocumentId: string | null;
|
||||
onDocumentSelect: (documentId: string) => void;
|
||||
onConfirmComplete: () => void;
|
||||
isCompleted: boolean;
|
||||
isMock?: boolean;
|
||||
onFileUpload?: (subItemId: string, file: File) => Promise<boolean>;
|
||||
uploadedFiles?: TemplateDocument[];
|
||||
onFileDelete?: (fileId: number) => void;
|
||||
onFileSelect?: (file: TemplateDocument) => void;
|
||||
selectedFileId?: number | null;
|
||||
}
|
||||
|
||||
export function Day1DocumentSection({
|
||||
checkItem,
|
||||
selectedDocumentId,
|
||||
onDocumentSelect,
|
||||
onConfirmComplete,
|
||||
isCompleted,
|
||||
isMock,
|
||||
onFileUpload,
|
||||
uploadedFiles = [],
|
||||
onFileDelete,
|
||||
onFileSelect,
|
||||
selectedFileId,
|
||||
}: Day1DocumentSectionProps) {
|
||||
if (!checkItem) {
|
||||
return (
|
||||
@@ -56,14 +36,7 @@ export function Day1DocumentSection({
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">기준 문서화</h3>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">기준 문서화</h3>
|
||||
</div>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
@@ -74,23 +47,19 @@ export function Day1DocumentSection({
|
||||
<p className="text-xs sm:text-sm text-blue-700">{checkItem.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 관련 기준 문서 */}
|
||||
{/* 기준 문서 목록 */}
|
||||
<div>
|
||||
<h5 className="text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2">관련 기준 문서</h5>
|
||||
<div className="space-y-1">
|
||||
{uploadedFiles.map((file) => (
|
||||
<UploadedFileRow
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={selectedFileId === file.id}
|
||||
onSelect={onFileSelect ? () => onFileSelect(file) : undefined}
|
||||
onDelete={onFileDelete ? () => onFileDelete(file.id) : undefined}
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
{checkItem.standardDocuments.map((doc) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
isSelected={selectedDocumentId === doc.id}
|
||||
onSelect={() => onDocumentSelect(doc.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<DocumentUploadArea
|
||||
onUpload={onFileUpload ? (file) => onFileUpload(checkItem.subItemId, file) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 확인 버튼 */}
|
||||
@@ -122,200 +91,67 @@ export function Day1DocumentSection({
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 파일 업로드 영역 =====
|
||||
|
||||
interface DocumentUploadAreaProps {
|
||||
onUpload?: (file: File) => Promise<boolean>;
|
||||
interface DocumentRowProps {
|
||||
document: StandardDocument;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const validateFile = useCallback((file: File): string | null => {
|
||||
const sizeMB = file.size / (1024 * 1024);
|
||||
if (sizeMB > MAX_FILE_SIZE_MB) {
|
||||
return `파일 크기는 ${MAX_FILE_SIZE_MB}MB 이하여야 합니다.`;
|
||||
}
|
||||
// 확장자 체크
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
const allowed = ['pdf', 'xlsx', 'xls', 'doc', 'docx', 'hwp'];
|
||||
if (!ext || !allowed.includes(ext)) {
|
||||
return 'PDF, Excel, Word, HWP 파일만 업로드 가능합니다.';
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const handleFile = useCallback((file: File) => {
|
||||
const error = validateFile(file);
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
return;
|
||||
}
|
||||
setPendingFile(file);
|
||||
}, [validateFile]);
|
||||
|
||||
const handleConfirmUpload = useCallback(async () => {
|
||||
if (!pendingFile || !onUpload) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
const success = await onUpload(pendingFile);
|
||||
if (success) {
|
||||
toast.success(`${pendingFile.name} 업로드 완료`);
|
||||
setPendingFile(null);
|
||||
}
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [pendingFile, onUpload]);
|
||||
|
||||
const handleCancelUpload = useCallback(() => {
|
||||
setPendingFile(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}, []);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
};
|
||||
|
||||
// 선택된 파일 미리보기
|
||||
if (pendingFile) {
|
||||
const ext = pendingFile.name.split('.').pop()?.toLowerCase();
|
||||
const sizeMB = (pendingFile.size / (1024 * 1024)).toFixed(1);
|
||||
return (
|
||||
<div className="mt-2 p-2.5 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(
|
||||
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center',
|
||||
ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||
)}>
|
||||
<FileText className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{pendingFile.name}</p>
|
||||
<p className="text-[10px] text-gray-500">{sizeMB} MB</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelUpload}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelUpload}
|
||||
className="flex-1 py-1.5 text-xs text-gray-600 bg-white border border-gray-200 rounded-md hover:bg-gray-50"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmUpload}
|
||||
disabled={uploading}
|
||||
className="flex-1 py-1.5 text-xs text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
업로드 중...
|
||||
</>
|
||||
) : (
|
||||
'업로드'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept={ACCEPTED_EXTENSIONS}
|
||||
onChange={handleInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
onDragEnter={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }}
|
||||
onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'mt-2 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border-2 border-dashed cursor-pointer transition-colors',
|
||||
isDragging
|
||||
? 'border-blue-400 bg-blue-50'
|
||||
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
|
||||
)}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5 text-gray-400" />
|
||||
<span className="text-xs text-gray-500">
|
||||
파일 업로드 (PDF, Excel, Word, HWP)
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 업로드된 파일 행 =====
|
||||
|
||||
interface UploadedFileRowProps {
|
||||
file: TemplateDocument;
|
||||
isSelected?: boolean;
|
||||
onSelect?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
function UploadedFileRow({ file, isSelected, onSelect, onDelete }: UploadedFileRowProps) {
|
||||
const ext = file.displayName.split('.').pop()?.toLowerCase();
|
||||
const sizeMB = (file.fileSize / (1024 * 1024)).toFixed(1);
|
||||
|
||||
function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-colors',
|
||||
'flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{/* 아이콘 */}
|
||||
<div className={cn(
|
||||
'flex-shrink-0 w-7 h-7 rounded flex items-center justify-center',
|
||||
ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||
'flex-shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center',
|
||||
document.fileName?.endsWith('.pdf')
|
||||
? 'bg-red-100 text-red-600'
|
||||
: 'bg-green-100 text-green-600'
|
||||
)}>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
<FileText className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</div>
|
||||
|
||||
{/* 문서 정보 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-gray-800 truncate">{file.displayName}</p>
|
||||
<p className="text-[10px] text-gray-400">{sizeMB} MB</p>
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{document.title}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
|
||||
<span>{document.date}</span>
|
||||
</p>
|
||||
</div>
|
||||
{onDelete && (
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="삭제"
|
||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
|
||||
title="미리보기"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelect();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
<Eye className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
|
||||
title="다운로드"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// TODO: 다운로드 기능
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,120 +3,13 @@
|
||||
import React from 'react';
|
||||
import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StandardDocument, TemplateDocument } from '../types';
|
||||
import type { StandardDocument } from '../types';
|
||||
|
||||
interface Day1DocumentViewerProps {
|
||||
document: StandardDocument | null;
|
||||
uploadedFile?: TemplateDocument | null;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export function Day1DocumentViewer({ document, uploadedFile, isMock }: Day1DocumentViewerProps) {
|
||||
// 업로드된 파일이 선택된 경우
|
||||
if (uploadedFile) {
|
||||
const isPdf = uploadedFile.mimeType === 'application/pdf';
|
||||
const viewUrl = `/api/proxy/files/${uploadedFile.id}/view`;
|
||||
const downloadUrl = `/api/proxy/files/${uploadedFile.id}/download`;
|
||||
|
||||
// Google Docs Viewer용 공개 URL 생성 (개발/운영 서버에서만 동작)
|
||||
const isLocalhost = typeof window !== 'undefined' && (
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname.endsWith('.sam.kr')
|
||||
);
|
||||
const publicFileUrl = typeof window !== 'undefined'
|
||||
? `${window.location.origin}${viewUrl}`
|
||||
: '';
|
||||
const googleViewerUrl = `https://docs.google.com/gview?url=${encodeURIComponent(publicFileUrl)}&embedded=true`;
|
||||
|
||||
const isOfficeDoc = [
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
].includes(uploadedFile.mimeType);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-6 h-6 sm:w-8 sm:h-8 rounded flex items-center justify-center ${
|
||||
isPdf ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||
}`}>
|
||||
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm truncate max-w-[200px]">
|
||||
{uploadedFile.displayName}
|
||||
</h3>
|
||||
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||
{(uploadedFile.fileSize / (1024 * 1024)).toFixed(1)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<a
|
||||
href={viewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="새 탭에서 보기"
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</a>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 미리보기 */}
|
||||
<div className="flex-1 bg-gray-200 overflow-auto">
|
||||
{isPdf ? (
|
||||
<iframe
|
||||
src={viewUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={uploadedFile.displayName}
|
||||
/>
|
||||
) : isOfficeDoc && !isLocalhost ? (
|
||||
<iframe
|
||||
src={googleViewerUrl}
|
||||
className="w-full h-full border-0"
|
||||
title={uploadedFile.displayName}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-400">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm">
|
||||
{isOfficeDoc && isLocalhost
|
||||
? '로컬 환경에서는 Office 문서 미리보기가 지원되지 않습니다'
|
||||
: '미리보기를 지원하지 않는 파일 형식입니다'}
|
||||
</p>
|
||||
<a
|
||||
href={downloadUrl}
|
||||
className="inline-block mt-2 text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
다운로드하여 확인
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-1.5 sm:py-2 border-t border-gray-200">
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 truncate">
|
||||
{uploadedFile.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
|
||||
@@ -145,14 +38,7 @@ export function Day1DocumentViewer({ document, uploadedFile, isMock }: Day1Docum
|
||||
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
|
||||
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
|
||||
{document.date}
|
||||
|
||||
@@ -11,7 +11,6 @@ interface DocumentListProps {
|
||||
documents: Document[];
|
||||
routeCode: string | null;
|
||||
onViewDocument: (doc: Document, item?: DocumentItem) => void;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
const getIcon = (type: string) => {
|
||||
@@ -28,7 +27,7 @@ const getIcon = (type: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: DocumentListProps) => {
|
||||
export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentListProps) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
// 문서 카테고리 클릭 핸들러
|
||||
@@ -53,24 +52,17 @@ export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: D
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
|
||||
관련 서류{' '}
|
||||
{routeCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
|
||||
)}
|
||||
</h2>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
|
||||
관련 서류{' '}
|
||||
{routeCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
|
||||
)}
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
||||
{!routeCode ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||
수주로트를 선택해주세요.
|
||||
수주루트를 선택해주세요.
|
||||
</div>
|
||||
) : (
|
||||
documents.map((doc) => {
|
||||
@@ -122,7 +114,7 @@ export const DocumentList = ({ documents, routeCode, onViewDocument, isMock }: D
|
||||
{item.code && (
|
||||
<>
|
||||
<span className="mx-1">|</span>
|
||||
{item.code}
|
||||
로트: {item.code}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { DocumentViewer } from '@/components/document-system';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { Document, DocumentItem } from '../types';
|
||||
import { getDocumentDetail } from '../actions';
|
||||
import { MOCK_SHIPMENT_DETAIL } from '../mockData';
|
||||
|
||||
// 기존 문서 컴포넌트 import
|
||||
@@ -25,12 +24,10 @@ import {
|
||||
QualityDocumentUploader,
|
||||
} from './documents';
|
||||
|
||||
// 제품검사 성적서 (FQC 양식) import
|
||||
import { FqcDocumentContent } from '@/components/quality/InspectionManagement/documents/FqcDocumentContent';
|
||||
// 제품검사 성적서 (신규 양식) import
|
||||
import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument';
|
||||
import type { FqcTemplate, FqcDocumentData } from '@/components/quality/InspectionManagement/fqcActions';
|
||||
import type { InspectionReportDocument as InspectionReportDocumentType, ProductInspectionData } from '@/components/quality/InspectionManagement/types';
|
||||
import { mockReportInspectionItems, mapInspectionDataToItems } from '@/components/quality/InspectionManagement/mockData';
|
||||
import { mockReportInspectionItems } from '@/components/quality/InspectionManagement/mockData';
|
||||
import type { InspectionReportDocument as InspectionReportDocumentType } from '@/components/quality/InspectionManagement/types';
|
||||
import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument';
|
||||
|
||||
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
|
||||
@@ -47,9 +44,6 @@ import type { WorkOrder } from '@/components/production/WorkOrders/types';
|
||||
// 검사 템플릿 API
|
||||
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
|
||||
|
||||
// 작업지시 상세 API (QMS 작업일지/중간검사용)
|
||||
import { getWorkOrderById } from '@/components/production/WorkOrders/actions';
|
||||
|
||||
/**
|
||||
* 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환
|
||||
*
|
||||
@@ -142,7 +136,7 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D
|
||||
)}
|
||||
{docItem?.code && (
|
||||
<p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4">
|
||||
문서번호: {docItem.code}
|
||||
로트 번호: {docItem.code}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
|
||||
@@ -166,32 +160,26 @@ const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [
|
||||
{ id: 'bf-1', itemCode: 'BF-001', itemName: '하단마감재', specification: 'EGI 1.5ST', type: '하단마감재', quantity: 8, unit: 'EA', unitPrice: 18000, supplyAmount: 144000, taxAmount: 14400, totalAmount: 158400, sortOrder: 5 },
|
||||
];
|
||||
|
||||
// FQC 문서 API 응답 → FqcTemplate 변환
|
||||
function transformFqcApiToTemplate(apiTemplate: Record<string, unknown>): FqcTemplate {
|
||||
const t = apiTemplate as {
|
||||
id: number; name: string; category: string; title: string | null;
|
||||
approval_lines: { id: number; name: string; department: string; sort_order: number }[];
|
||||
basic_fields: { id: number; label: string; field_key: string; field_type: string; default_value: string | null; is_required: boolean; sort_order: number }[];
|
||||
sections: { id: number; name: string; title: string | null; description: string | null; image_path: string | null; sort_order: number;
|
||||
items: { id: number; section_id: number; item_name: string; standard: string | null; tolerance: string | null; measurement_type: string; frequency: string; sort_order: number; category: string; method: string }[];
|
||||
}[];
|
||||
columns: { id: number; label: string; column_type: string; width: string | null; group_name: string | null; sort_order: number }[];
|
||||
};
|
||||
return {
|
||||
id: t.id, name: t.name, category: t.category, title: t.title,
|
||||
approvalLines: (t.approval_lines || []).map(a => ({ id: a.id, name: a.name, department: a.department, sortOrder: a.sort_order })),
|
||||
basicFields: (t.basic_fields || []).map(f => ({ id: f.id, label: f.label, fieldKey: f.field_key, fieldType: f.field_type, defaultValue: f.default_value, isRequired: f.is_required, sortOrder: f.sort_order })),
|
||||
sections: (t.sections || []).map(s => ({
|
||||
id: s.id, name: s.name, title: s.title, description: s.description, imagePath: s.image_path, sortOrder: s.sort_order,
|
||||
items: (s.items || []).map(i => ({ id: i.id, sectionId: i.section_id, itemName: i.item_name, standard: i.standard, tolerance: i.tolerance, measurementType: i.measurement_type, frequency: i.frequency, sortOrder: i.sort_order, category: i.category || '', method: i.method || '' })),
|
||||
})),
|
||||
columns: (t.columns || []).map(c => ({ id: c.id, label: c.label, columnType: c.column_type, width: c.width, groupName: c.group_name ?? null, sortOrder: c.sort_order })),
|
||||
};
|
||||
}
|
||||
|
||||
function transformFqcApiToData(apiData: { section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }[]): FqcDocumentData[] {
|
||||
return (apiData || []).map(d => ({ sectionId: d.section_id, columnId: d.column_id, rowIndex: d.row_index, fieldKey: d.field_key, fieldValue: d.field_value }));
|
||||
}
|
||||
// QMS용 제품검사 성적서 Mock 데이터
|
||||
const QMS_MOCK_REPORT_DATA: InspectionReportDocumentType = {
|
||||
documentNumber: 'RPT-KD-SS-2024-530',
|
||||
createdDate: '2024-09-24',
|
||||
approvalLine: [
|
||||
{ role: '작성', name: '김검사', department: '품질관리부' },
|
||||
{ role: '승인', name: '박승인', department: '품질관리부' },
|
||||
],
|
||||
productName: '방화스크린',
|
||||
productLotNo: 'KD-SS-240924-19',
|
||||
productCode: 'WY-SC780',
|
||||
lotSize: '8',
|
||||
client: '삼성물산(주)',
|
||||
inspectionDate: '2024-09-26',
|
||||
siteName: '강남 아파트 단지',
|
||||
inspector: '김검사',
|
||||
inspectionItems: mockReportInspectionItems,
|
||||
specialNotes: '',
|
||||
finalJudgment: '합격',
|
||||
};
|
||||
|
||||
// QMS용 작업일지 Mock WorkOrder 생성
|
||||
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({
|
||||
@@ -282,24 +270,10 @@ export const InspectionModal = ({
|
||||
const [isLoadingTemplate, setIsLoadingTemplate] = useState(false);
|
||||
const [templateError, setTemplateError] = useState<string | null>(null);
|
||||
|
||||
// 작업일지/중간검사용 WorkOrder 상태
|
||||
const [workOrderData, setWorkOrderData] = useState<WorkOrder | null>(null);
|
||||
const [isLoadingWorkOrder, setIsLoadingWorkOrder] = useState(false);
|
||||
const [workOrderError, setWorkOrderError] = useState<string | null>(null);
|
||||
|
||||
// 수입검사 저장용 ref/상태
|
||||
const importDocRef = useRef<ImportInspectionRef>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 제품검사 성적서 FQC 상태
|
||||
const [fqcTemplate, setFqcTemplate] = useState<FqcTemplate | null>(null);
|
||||
const [fqcData, setFqcData] = useState<FqcDocumentData[]>([]);
|
||||
const [fqcDocumentNo, setFqcDocumentNo] = useState<string>('');
|
||||
const [isLoadingFqc, setIsLoadingFqc] = useState(false);
|
||||
const [fqcError, setFqcError] = useState<string | null>(null);
|
||||
// 레거시 inspection_data 기반 제품검사 성적서
|
||||
const [legacyReportData, setLegacyReportData] = useState<InspectionReportDocumentType | null>(null);
|
||||
|
||||
// 수입검사 템플릿 로드 (모달 열릴 때)
|
||||
useEffect(() => {
|
||||
// itemId가 있으면 실제 API로 조회, 없으면 itemName/specification으로 mock 조회
|
||||
@@ -311,53 +285,9 @@ export const InspectionModal = ({
|
||||
setImportTemplate(null);
|
||||
setImportInitialValues(undefined);
|
||||
setTemplateError(null);
|
||||
setFqcTemplate(null);
|
||||
setFqcData([]);
|
||||
setFqcDocumentNo('');
|
||||
setFqcError(null);
|
||||
setLegacyReportData(null);
|
||||
setWorkOrderData(null);
|
||||
setWorkOrderError(null);
|
||||
}
|
||||
}, [isOpen, doc?.type, itemId, itemName, specification]);
|
||||
|
||||
// 작업일지/중간검사 WorkOrder 로드 (모달 열릴 때)
|
||||
// log: documentItem.id === work_order_id, report: documentItem.workOrderId로 전달
|
||||
useEffect(() => {
|
||||
if (isOpen && (doc?.type === 'log' || doc?.type === 'report')) {
|
||||
const woId = documentItem?.workOrderId || (doc?.type === 'log' ? Number(documentItem?.id) : null);
|
||||
if (woId) {
|
||||
loadWorkOrderData(woId);
|
||||
}
|
||||
}
|
||||
}, [isOpen, doc?.type, documentItem?.workOrderId, documentItem?.id]);
|
||||
|
||||
// 제품검사 성적서 FQC 로드 (모달 열릴 때)
|
||||
useEffect(() => {
|
||||
if (isOpen && doc?.type === 'product' && documentItem?.id) {
|
||||
loadFqcDocument(documentItem.id);
|
||||
}
|
||||
}, [isOpen, doc?.type, documentItem?.id]);
|
||||
|
||||
const loadWorkOrderData = async (workOrderId: number) => {
|
||||
setIsLoadingWorkOrder(true);
|
||||
setWorkOrderError(null);
|
||||
|
||||
try {
|
||||
const result = await getWorkOrderById(String(workOrderId));
|
||||
if (result.success && result.data) {
|
||||
setWorkOrderData(result.data);
|
||||
} else {
|
||||
setWorkOrderError(result.error || '작업지시 데이터를 불러올 수 없습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[InspectionModal] loadWorkOrderData error:', error);
|
||||
setWorkOrderError('작업지시 데이터 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoadingWorkOrder(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadInspectionTemplate = async () => {
|
||||
// itemId가 있으면 실제 API 호출, 없으면 itemName/specification 필요
|
||||
if (!itemId && (!itemName || !specification)) return;
|
||||
@@ -400,78 +330,11 @@ export const InspectionModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 제품검사 성적서 문서 로드 (FQC 우선, inspection_data fallback)
|
||||
const loadFqcDocument = async (locationId: string) => {
|
||||
setIsLoadingFqc(true);
|
||||
setFqcError(null);
|
||||
setLegacyReportData(null);
|
||||
|
||||
try {
|
||||
const result = await getDocumentDetail('product', locationId);
|
||||
if (result.success && result.data) {
|
||||
const data = result.data as {
|
||||
document_id: number | null;
|
||||
inspection_status: string | null;
|
||||
inspection_data: ProductInspectionData | null;
|
||||
floor_code: string | null;
|
||||
symbol_code: string | null;
|
||||
fqc_document?: {
|
||||
document_no: string;
|
||||
template: Record<string, unknown>;
|
||||
data: { section_id: number | null; column_id: number | null; row_index: number; field_key: string; field_value: string | null }[];
|
||||
};
|
||||
};
|
||||
|
||||
if (data.fqc_document) {
|
||||
// FQC 문서가 있는 경우
|
||||
setFqcTemplate(transformFqcApiToTemplate(data.fqc_document.template));
|
||||
setFqcData(transformFqcApiToData(data.fqc_document.data));
|
||||
setFqcDocumentNo(data.fqc_document.document_no || '');
|
||||
} else if (data.inspection_data && data.inspection_status === 'completed') {
|
||||
// FQC 없지만 inspection_data가 있는 경우 → 레거시 리포트 생성
|
||||
const inspData = data.inspection_data;
|
||||
const mappedItems = mapInspectionDataToItems(mockReportInspectionItems, inspData);
|
||||
const locationLabel = [data.floor_code, data.symbol_code].filter(Boolean).join(' ');
|
||||
|
||||
setLegacyReportData({
|
||||
documentNumber: '',
|
||||
createdDate: '',
|
||||
approvalLine: [
|
||||
{ role: '작성', name: '', department: '' },
|
||||
{ role: '승인', name: '', department: '' },
|
||||
],
|
||||
productName: inspData.productName || '',
|
||||
productLotNo: '',
|
||||
productCode: '',
|
||||
lotSize: '1',
|
||||
client: '',
|
||||
inspectionDate: '',
|
||||
siteName: locationLabel,
|
||||
inspector: '',
|
||||
productImages: inspData.productImages || [],
|
||||
inspectionItems: mappedItems,
|
||||
specialNotes: inspData.specialNotes || '',
|
||||
finalJudgment: '합격',
|
||||
});
|
||||
} else {
|
||||
setFqcError('제품검사 성적서 문서가 아직 생성되지 않았습니다.');
|
||||
}
|
||||
} else {
|
||||
setFqcError(result.error || '제품검사 성적서 조회에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[InspectionModal] loadFqcDocument error:', error);
|
||||
setFqcError('제품검사 성적서 로드 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoadingFqc(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함)
|
||||
const handleImportSave = useCallback(async () => {
|
||||
if (!importDocRef.current) return;
|
||||
|
||||
const _data = importDocRef.current.getInspectionData();
|
||||
const data = importDocRef.current.getInspectionData();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TODO: 실제 저장 API 연동
|
||||
@@ -487,73 +350,49 @@ export const InspectionModal = ({
|
||||
|
||||
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
|
||||
const subtitle = documentItem
|
||||
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` ${documentItem.code}` : ''}`
|
||||
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` 로트: ${documentItem.code}` : ''}`
|
||||
: docInfo.label;
|
||||
|
||||
// 품질관리서 PDF 업로드 핸들러
|
||||
const handleQualityFileUpload = (_file: File) => {
|
||||
const handleQualityFileUpload = (file: File) => {
|
||||
};
|
||||
|
||||
const handleQualityFileDelete = () => {
|
||||
};
|
||||
|
||||
// 작업일지/중간검사 공통: WorkOrder 데이터 로딩 상태 처리
|
||||
const renderWorkOrderLoading = () => {
|
||||
if (isLoadingWorkOrder) {
|
||||
return (
|
||||
<div className="bg-white shadow-sm p-16 w-full h-full rounded flex flex-col items-center justify-center text-center">
|
||||
<Loader2 className="w-12 h-12 text-blue-500 animate-spin mb-4" />
|
||||
<p className="text-gray-600 text-sm">작업지시 데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (workOrderError) {
|
||||
return <ErrorDocument message={workOrderError} onRetry={documentItem?.workOrderId ? () => loadWorkOrderData(documentItem.workOrderId!) : undefined} />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 작업일지 공정별 렌더링
|
||||
const renderWorkLogDocument = () => {
|
||||
const loadingEl = renderWorkOrderLoading();
|
||||
if (loadingEl) return loadingEl;
|
||||
|
||||
const subType = documentItem?.subType;
|
||||
// 실제 WorkOrder 데이터 사용, 없으면 fallback mock
|
||||
const orderData = workOrderData || createQmsMockWorkOrder(subType);
|
||||
const mockOrder = createQmsMockWorkOrder(subType);
|
||||
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenWorkLogContent data={orderData} />;
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
case 'slat':
|
||||
return <SlatWorkLogContent data={orderData} />;
|
||||
return <SlatWorkLogContent data={mockOrder} />;
|
||||
case 'bending':
|
||||
return <BendingWorkLogContent data={orderData} />;
|
||||
return <BendingWorkLogContent data={mockOrder} />;
|
||||
default:
|
||||
return <ScreenWorkLogContent data={orderData} />;
|
||||
// subType 미지정 시 스크린 기본
|
||||
return <ScreenWorkLogContent data={mockOrder} />;
|
||||
}
|
||||
};
|
||||
|
||||
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
|
||||
const renderReportDocument = () => {
|
||||
const loadingEl = renderWorkOrderLoading();
|
||||
if (loadingEl) return loadingEl;
|
||||
|
||||
const subType = documentItem?.subType;
|
||||
// 실제 WorkOrder 데이터 사용, 없으면 fallback mock
|
||||
const orderData = workOrderData || createQmsMockWorkOrder(subType || 'screen');
|
||||
|
||||
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
|
||||
switch (subType) {
|
||||
case 'screen':
|
||||
return <ScreenInspectionContent data={orderData} readOnly />;
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
case 'bending':
|
||||
return <BendingInspectionContent data={orderData} readOnly />;
|
||||
return <BendingInspectionContent data={mockOrder} readOnly />;
|
||||
case 'slat':
|
||||
return <SlatInspectionContent data={orderData} readOnly />;
|
||||
return <SlatInspectionContent data={mockOrder} readOnly />;
|
||||
case 'jointbar':
|
||||
return <JointbarInspectionDocument />;
|
||||
default:
|
||||
return <ScreenInspectionContent data={orderData} readOnly />;
|
||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -579,36 +418,6 @@ export const InspectionModal = ({
|
||||
);
|
||||
};
|
||||
|
||||
// 제품검사 성적서 렌더링 (FQC 우선, inspection_data fallback)
|
||||
const renderProductDocument = () => {
|
||||
if (isLoadingFqc) {
|
||||
return <LoadingDocument />;
|
||||
}
|
||||
|
||||
if (fqcError) {
|
||||
return <ErrorDocument message={fqcError} onRetry={documentItem?.id ? () => loadFqcDocument(documentItem.id) : undefined} />;
|
||||
}
|
||||
|
||||
// FQC 문서 기반 렌더링
|
||||
if (fqcTemplate) {
|
||||
return (
|
||||
<FqcDocumentContent
|
||||
template={fqcTemplate}
|
||||
documentData={fqcData}
|
||||
documentNo={fqcDocumentNo}
|
||||
readonly
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 레거시 inspection_data 기반 렌더링
|
||||
if (legacyReportData) {
|
||||
return <InspectionReportDocument data={legacyReportData} />;
|
||||
}
|
||||
|
||||
return <PlaceholderDocument docType="product" docItem={documentItem} />;
|
||||
};
|
||||
|
||||
// 문서 타입에 따른 컨텐츠 렌더링
|
||||
const renderDocumentContent = () => {
|
||||
switch (doc.type) {
|
||||
@@ -644,7 +453,7 @@ export const InspectionModal = ({
|
||||
case 'import':
|
||||
return renderImportInspectionDocument();
|
||||
case 'product':
|
||||
return renderProductDocument();
|
||||
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
|
||||
case 'report':
|
||||
return renderReportDocument();
|
||||
case 'quality':
|
||||
|
||||
@@ -8,21 +8,13 @@ interface ReportListProps {
|
||||
reports: InspectionReport[];
|
||||
selectedId: string | null;
|
||||
onSelect: (report: InspectionReport) => void;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportListProps) => {
|
||||
export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="font-bold text-sm sm:text-lg text-gray-800">품질관리서 목록</h2>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="font-bold text-sm sm:text-lg text-gray-800">품질관리서 목록</h2>
|
||||
<span className="bg-blue-100 text-blue-800 text-[10px] sm:text-xs font-bold px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full">
|
||||
{reports.length}건
|
||||
</span>
|
||||
@@ -40,20 +32,19 @@ export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportList
|
||||
<div
|
||||
key={report.id}
|
||||
onClick={() => onSelect(report)}
|
||||
className={`rounded-lg p-3 sm:p-4 cursor-pointer hover:shadow-md transition-all ${
|
||||
className={`rounded-lg p-3 sm:p-4 cursor-pointer relative hover:shadow-md transition-all ${
|
||||
isSelected
|
||||
? 'border-2 border-blue-500 bg-blue-50'
|
||||
: 'border border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<h3 className={`font-bold text-sm sm:text-lg ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
|
||||
{report.code}
|
||||
</h3>
|
||||
<span className="text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded whitespace-nowrap">
|
||||
{report.quarter}
|
||||
</span>
|
||||
<div className="absolute top-3 sm:top-4 right-3 sm:right-4 text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded">
|
||||
{report.quarter}
|
||||
</div>
|
||||
|
||||
<h3 className={`font-bold text-sm sm:text-lg mb-1 ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
|
||||
{report.code}
|
||||
</h3>
|
||||
<p className="text-xs sm:text-base text-gray-700 font-medium mb-1">{report.siteName}</p>
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3">인정품목: {report.item}</p>
|
||||
|
||||
@@ -61,7 +52,7 @@ export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportList
|
||||
isSelected ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
<Package size={16} />
|
||||
<span>수주로트 {report.routeCount}건</span>
|
||||
<span>수주루트 {report.routeCount}건</span>
|
||||
<span className="text-gray-400 text-xs ml-1">(총 {report.totalRoutes}개소)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,9 @@ interface RouteListProps {
|
||||
onSelect: (route: RouteItem) => void;
|
||||
onToggleItem: (routeId: string, itemId: string, isCompleted: boolean) => void;
|
||||
reportCode: string | null;
|
||||
isMock?: boolean;
|
||||
}
|
||||
|
||||
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode, isMock }: RouteListProps) => {
|
||||
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode }: RouteListProps) => {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const handleClick = (route: RouteItem) => {
|
||||
@@ -29,24 +28,17 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
|
||||
수주로트 목록{' '}
|
||||
{reportCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
|
||||
)}
|
||||
</h2>
|
||||
{isMock && (
|
||||
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||
Mock
|
||||
</span>
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
|
||||
수주루트 목록{' '}
|
||||
{reportCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
|
||||
)}
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
||||
{routes.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||
{reportCode ? '수주로트가 없습니다.' : '품질관리서를 선택해주세요.'}
|
||||
{reportCode ? '수주루트가 없습니다.' : '품질관리서를 선택해주세요.'}
|
||||
</div>
|
||||
) : (
|
||||
routes.map((route) => {
|
||||
@@ -80,9 +72,8 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mb-0.5">수주일: {route.date || '-'}</p>
|
||||
{route.client && <p className="text-xs text-gray-500 mb-0.5">거래처: {route.client}</p>}
|
||||
<p className="text-xs text-gray-500 mb-2">현장: {route.site || '-'}</p>
|
||||
<p className="text-xs text-gray-500 mb-1">수주일: {route.date}</p>
|
||||
<p className="text-xs text-gray-500 mb-2">현장: {route.site}</p>
|
||||
<div className="inline-flex items-center gap-1 bg-gray-100 px-2 py-0.5 rounded text-xs text-gray-600">
|
||||
<MapPin size={10} />
|
||||
<span>{route.locationCount}개소</span>
|
||||
|
||||
@@ -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 } from 'lucide-react';
|
||||
import { Upload, FileText, Download, Trash2, Eye, RefreshCw, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export interface QualityDocumentFile {
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { ChecklistCategory } from '../types';
|
||||
import { getChecklistTemplate, saveChecklistTemplate } from '../actions';
|
||||
|
||||
function generateId() {
|
||||
return `item-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function useChecklistTemplate() {
|
||||
const [templateId, setTemplateId] = useState<number | null>(null);
|
||||
const [editCategories, setEditCategories] = useState<ChecklistCategory[]>([]);
|
||||
const savedRef = useRef<ChecklistCategory[]>([]);
|
||||
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
// === 초기 로드 ===
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
getChecklistTemplate('day1_audit')
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.success && result.data) {
|
||||
setTemplateId(result.data.id);
|
||||
const cats = result.data.categories;
|
||||
setEditCategories(structuredClone(cats));
|
||||
savedRef.current = structuredClone(cats);
|
||||
} else {
|
||||
setError(result.error || '템플릿 로드 실패');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError('템플릿 로드 중 오류 발생');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// === 변경 추적 ===
|
||||
const markChanged = useCallback(() => setHasChanges(true), []);
|
||||
|
||||
// === 카테고리 CRUD ===
|
||||
const addCategory = useCallback(() => {
|
||||
setEditCategories(prev => [
|
||||
...prev,
|
||||
{ id: generateId(), title: '새 카테고리', subItems: [] },
|
||||
]);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const updateCategoryTitle = useCallback((categoryId: string, title: string) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => cat.id === categoryId ? { ...cat, title } : cat)
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const deleteCategory = useCallback((categoryId: string) => {
|
||||
setEditCategories(prev => prev.filter(cat => cat.id !== categoryId));
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
// === 카테고리 순서 변경 ===
|
||||
const moveCategoryUp = useCallback((index: number) => {
|
||||
if (index <= 0) return;
|
||||
setEditCategories(prev => {
|
||||
const next = [...prev];
|
||||
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
||||
return next;
|
||||
});
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const moveCategoryDown = useCallback((index: number) => {
|
||||
setEditCategories(prev => {
|
||||
if (index >= prev.length - 1) return prev;
|
||||
const next = [...prev];
|
||||
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
||||
return next;
|
||||
});
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
// === 하위 항목 CRUD ===
|
||||
const addSubItem = useCallback((categoryId: string) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: [
|
||||
...cat.subItems,
|
||||
{ id: generateId(), name: '새 항목', isCompleted: false },
|
||||
],
|
||||
};
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const updateSubItemName = useCallback((categoryId: string, subItemId: string, name: string) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.map(item =>
|
||||
item.id === subItemId ? { ...item, name } : item
|
||||
),
|
||||
};
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const deleteSubItem = useCallback((categoryId: string, subItemId: string) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.filter(item => item.id !== subItemId),
|
||||
};
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
// === 하위 항목 순서 변경 ===
|
||||
const moveSubItemUp = useCallback((categoryId: string, index: number) => {
|
||||
if (index <= 0) return;
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
const items = [...cat.subItems];
|
||||
[items[index - 1], items[index]] = [items[index], items[index - 1]];
|
||||
return { ...cat, subItems: items };
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
const moveSubItemDown = useCallback((categoryId: string, index: number) => {
|
||||
setEditCategories(prev =>
|
||||
prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
if (index >= cat.subItems.length - 1) return cat;
|
||||
const items = [...cat.subItems];
|
||||
[items[index], items[index + 1]] = [items[index + 1], items[index]];
|
||||
return { ...cat, subItems: items };
|
||||
})
|
||||
);
|
||||
markChanged();
|
||||
}, [markChanged]);
|
||||
|
||||
// === 저장 ===
|
||||
const saveTemplate = useCallback(async () => {
|
||||
if (!templateId) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// API용 데이터: isCompleted 제거
|
||||
const apiCategories = editCategories.map(cat => ({
|
||||
id: cat.id,
|
||||
title: cat.title,
|
||||
subItems: cat.subItems.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
})),
|
||||
}));
|
||||
|
||||
const result = await saveChecklistTemplate(templateId, {
|
||||
categories: apiCategories,
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
const cats = result.data.categories;
|
||||
setEditCategories(structuredClone(cats));
|
||||
savedRef.current = structuredClone(cats);
|
||||
setHasChanges(false);
|
||||
toast.success('점검표가 저장되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editCategories, templateId]);
|
||||
|
||||
// === 초기화 ===
|
||||
const resetToSaved = useCallback(() => {
|
||||
setEditCategories(structuredClone(savedRef.current));
|
||||
setHasChanges(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 데이터
|
||||
templateId,
|
||||
editCategories,
|
||||
hasChanges,
|
||||
saving,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// 카테고리
|
||||
addCategory,
|
||||
updateCategoryTitle,
|
||||
deleteCategory,
|
||||
moveCategoryUp,
|
||||
moveCategoryDown,
|
||||
|
||||
// 하위 항목
|
||||
addSubItem,
|
||||
updateSubItemName,
|
||||
deleteSubItem,
|
||||
moveSubItemUp,
|
||||
moveSubItemDown,
|
||||
|
||||
// 저장/초기화
|
||||
saveTemplate,
|
||||
resetToSaved,
|
||||
};
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { ChecklistCategory } from '../types';
|
||||
import { getChecklistTemplate, toggleTemplateItem } from '../actions';
|
||||
|
||||
export function useDay1Audit() {
|
||||
// 데이터 상태
|
||||
const [templateId, setTemplateId] = useState<number | null>(null);
|
||||
const [categories, setCategories] = useState<ChecklistCategory[]>([]);
|
||||
|
||||
// 선택 상태
|
||||
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
|
||||
// 로딩 상태
|
||||
const [loadingChecklist, setLoadingChecklist] = useState(true);
|
||||
const [pendingToggleIds, setPendingToggleIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 마운트 시 점검표 로드 (checklist_templates API)
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingChecklist(true);
|
||||
|
||||
getChecklistTemplate('day1_audit')
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
if (result.success && result.data) {
|
||||
setTemplateId(result.data.id);
|
||||
setCategories(result.data.categories);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingChecklist(false);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// 진행률 계산
|
||||
const day1Progress = useMemo(() => {
|
||||
const total = categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
|
||||
const completed = categories.reduce(
|
||||
(sum, cat) => sum + cat.subItems.filter((item) => item.isCompleted).length,
|
||||
0,
|
||||
);
|
||||
return { completed, total };
|
||||
}, [categories]);
|
||||
|
||||
// 선택된 항목의 완료 여부
|
||||
const isSelectedItemCompleted = useMemo(() => {
|
||||
if (!selectedSubItemId) return false;
|
||||
for (const cat of categories) {
|
||||
const item = cat.subItems.find((sub) => sub.id === selectedSubItemId);
|
||||
if (item) return item.isCompleted;
|
||||
}
|
||||
return false;
|
||||
}, [categories, selectedSubItemId]);
|
||||
|
||||
// 선택된 점검 항목 정보 (중앙 패널용)
|
||||
const selectedCheckItem = useMemo(() => {
|
||||
if (!selectedSubItemId || !selectedCategoryId) return null;
|
||||
const category = categories.find((c) => c.id === selectedCategoryId);
|
||||
if (!category) return null;
|
||||
const subItem = category.subItems.find((s) => s.id === selectedSubItemId);
|
||||
if (!subItem) return null;
|
||||
|
||||
return {
|
||||
id: `check-${subItem.id}`,
|
||||
categoryId: category.id,
|
||||
subItemId: subItem.id,
|
||||
title: subItem.name,
|
||||
description: '',
|
||||
buttonLabel: '기준/매뉴얼 확인',
|
||||
standardDocuments: [],
|
||||
};
|
||||
}, [selectedSubItemId, selectedCategoryId, categories]);
|
||||
|
||||
// === 핸들러 ===
|
||||
|
||||
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
|
||||
setSelectedCategoryId(categoryId);
|
||||
setSelectedSubItemId(subItemId);
|
||||
}, []);
|
||||
|
||||
const handleSubItemToggle = useCallback(async (categoryId: string, subItemId: string) => {
|
||||
if (!templateId || pendingToggleIds.has(subItemId)) return;
|
||||
setPendingToggleIds((prev) => new Set(prev).add(subItemId));
|
||||
|
||||
try {
|
||||
const result = await toggleTemplateItem(templateId, subItemId);
|
||||
if (result.success && result.data) {
|
||||
setCategories((prev) =>
|
||||
prev.map((cat) => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.map((item) => {
|
||||
if (item.id !== subItemId) return item;
|
||||
return { ...item, isCompleted: result.data!.isCompleted };
|
||||
}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
toast.error(result.error || '항목 상태 변경에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setPendingToggleIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(subItemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [templateId, pendingToggleIds]);
|
||||
|
||||
const handleConfirmComplete = useCallback(() => {
|
||||
if (selectedCategoryId && selectedSubItemId) {
|
||||
handleSubItemToggle(selectedCategoryId, selectedSubItemId);
|
||||
}
|
||||
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
|
||||
|
||||
return {
|
||||
// 데이터
|
||||
templateId,
|
||||
categories,
|
||||
day1Progress,
|
||||
selectedCheckItem,
|
||||
isSelectedItemCompleted,
|
||||
|
||||
// 선택
|
||||
selectedSubItemId,
|
||||
handleSubItemSelect,
|
||||
|
||||
// 토글
|
||||
handleSubItemToggle,
|
||||
handleConfirmComplete,
|
||||
pendingToggleIds,
|
||||
|
||||
// 로딩
|
||||
loadingChecklist,
|
||||
|
||||
// Mock 여부
|
||||
isMock: false,
|
||||
};
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { InspectionReport, RouteItem, Document, DocumentItem } from '../types';
|
||||
import {
|
||||
getQualityReports,
|
||||
getReportRoutes,
|
||||
getRouteDocuments,
|
||||
confirmUnitInspection,
|
||||
} from '../actions';
|
||||
|
||||
const USE_MOCK = false;
|
||||
|
||||
export function useDay2LotAudit() {
|
||||
// 필터 상태
|
||||
const [selectedYear, setSelectedYear] = useState(2026);
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 데이터 상태
|
||||
const [reports, setReports] = useState<InspectionReport[]>([]);
|
||||
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>({});
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
|
||||
// 선택 상태
|
||||
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
|
||||
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
|
||||
|
||||
// 모달 상태
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null);
|
||||
|
||||
// 로딩 상태
|
||||
const [loadingReports, setLoadingReports] = useState(false);
|
||||
const [loadingRoutes, setLoadingRoutes] = useState(false);
|
||||
const [loadingDocuments, setLoadingDocuments] = useState(false);
|
||||
const [pendingConfirmIds, setPendingConfirmIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 마운트 시 + 필터 변경 시 보고서 자동 로드
|
||||
useEffect(() => {
|
||||
if (USE_MOCK) return;
|
||||
|
||||
const loadReports = async () => {
|
||||
setLoadingReports(true);
|
||||
try {
|
||||
const quarterNum = selectedQuarter !== '전체'
|
||||
? parseInt(selectedQuarter.replace('Q', ''))
|
||||
: undefined;
|
||||
const result = await getQualityReports({
|
||||
year: selectedYear,
|
||||
quarter: quarterNum,
|
||||
q: searchTerm || undefined,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
setReports(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoadingReports(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadReports();
|
||||
}, [selectedYear, selectedQuarter, searchTerm]);
|
||||
|
||||
// 진행률 계산
|
||||
const day2Progress = useMemo(() => {
|
||||
let completed = 0;
|
||||
let total = 0;
|
||||
Object.values(routesData).forEach((routes) => {
|
||||
routes.forEach((route) => {
|
||||
route.subItems.forEach((item) => {
|
||||
total++;
|
||||
if (item.isCompleted) completed++;
|
||||
});
|
||||
});
|
||||
});
|
||||
return { completed, total };
|
||||
}, [routesData]);
|
||||
|
||||
// 필터링된 보고서 (API에서 이미 필터링되므로 그대로 반환)
|
||||
const filteredReports = useMemo(() => {
|
||||
return reports;
|
||||
}, [reports]);
|
||||
|
||||
// 현재 루트/문서
|
||||
const currentRoutes = useMemo(() => {
|
||||
if (!selectedReport) return [];
|
||||
return routesData[selectedReport.id] || [];
|
||||
}, [selectedReport, routesData]);
|
||||
|
||||
const currentDocuments = useMemo(() => {
|
||||
return documents;
|
||||
}, [documents]);
|
||||
|
||||
// === API 호출 핸들러 ===
|
||||
|
||||
const handleReportSelect = useCallback(async (report: InspectionReport) => {
|
||||
setSelectedReport(report);
|
||||
setSelectedRoute(null);
|
||||
setDocuments([]);
|
||||
|
||||
setLoadingRoutes(true);
|
||||
try {
|
||||
const result = await getReportRoutes(report.id);
|
||||
if (result.success && result.data) {
|
||||
setRoutesData((prev) => ({ ...prev, [report.id]: result.data! }));
|
||||
}
|
||||
} finally {
|
||||
setLoadingRoutes(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleRouteSelect = useCallback(async (route: RouteItem) => {
|
||||
setSelectedRoute(route);
|
||||
|
||||
setLoadingDocuments(true);
|
||||
try {
|
||||
const result = await getRouteDocuments(route.id);
|
||||
if (result.success && result.data) {
|
||||
setDocuments(result.data);
|
||||
}
|
||||
} finally {
|
||||
setLoadingDocuments(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleViewDocument = useCallback((doc: Document, item?: DocumentItem) => {
|
||||
setSelectedDoc(doc);
|
||||
setSelectedDocItem(item || null);
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleToggleItem = useCallback(async (routeId: string, itemId: string, isCompleted: boolean) => {
|
||||
// API: 비관적 업데이트
|
||||
if (pendingConfirmIds.has(itemId)) return;
|
||||
setPendingConfirmIds((prev) => new Set(prev).add(itemId));
|
||||
|
||||
try {
|
||||
const result = await confirmUnitInspection(itemId, isCompleted);
|
||||
if (result.success && result.data) {
|
||||
setRoutesData((prev) => {
|
||||
const newData = { ...prev };
|
||||
for (const reportId of Object.keys(newData)) {
|
||||
newData[reportId] = newData[reportId].map((route) => {
|
||||
if (route.id !== routeId) return route;
|
||||
return {
|
||||
...route,
|
||||
subItems: route.subItems.map((item) => {
|
||||
if (item.id !== itemId) return item;
|
||||
return { ...item, isCompleted: result.data!.isCompleted };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '확인 상태 변경에 실패했습니다.');
|
||||
}
|
||||
} finally {
|
||||
setPendingConfirmIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(itemId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [pendingConfirmIds]);
|
||||
|
||||
const handleYearChange = useCallback((year: number) => {
|
||||
setSelectedYear(year);
|
||||
setSelectedReport(null);
|
||||
setSelectedRoute(null);
|
||||
setDocuments([]);
|
||||
}, []);
|
||||
|
||||
const handleQuarterChange = useCallback((quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
|
||||
setSelectedQuarter(quarter);
|
||||
setSelectedReport(null);
|
||||
setSelectedRoute(null);
|
||||
setDocuments([]);
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = useCallback((term: string) => {
|
||||
setSearchTerm(term);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 필터
|
||||
selectedYear,
|
||||
selectedQuarter,
|
||||
searchTerm,
|
||||
handleYearChange,
|
||||
handleQuarterChange,
|
||||
handleSearchChange,
|
||||
|
||||
// 데이터
|
||||
filteredReports,
|
||||
currentRoutes,
|
||||
currentDocuments,
|
||||
day2Progress,
|
||||
|
||||
// 선택
|
||||
selectedReport,
|
||||
selectedRoute,
|
||||
handleReportSelect,
|
||||
handleRouteSelect,
|
||||
|
||||
// 모달
|
||||
modalOpen,
|
||||
selectedDoc,
|
||||
selectedDocItem,
|
||||
handleViewDocument,
|
||||
setModalOpen,
|
||||
|
||||
// 토글
|
||||
handleToggleItem,
|
||||
pendingConfirmIds,
|
||||
|
||||
// 로딩
|
||||
loadingReports,
|
||||
loadingRoutes,
|
||||
loadingDocuments,
|
||||
|
||||
// Mock 여부
|
||||
isMock: USE_MOCK,
|
||||
};
|
||||
}
|
||||
@@ -98,14 +98,13 @@ export const MOCK_REPORTS: InspectionReport[] = [
|
||||
},
|
||||
];
|
||||
|
||||
// 수주로트 목록 (reportId로 연결)
|
||||
// 수주루트 목록 (reportId로 연결)
|
||||
export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
||||
'1': [
|
||||
{
|
||||
id: '1-1',
|
||||
code: 'KD-SS-240924-19',
|
||||
date: '2024-09-24',
|
||||
client: '(주)강남건설',
|
||||
site: '강남 아파트 A동',
|
||||
locationCount: 7,
|
||||
subItems: [
|
||||
@@ -122,7 +121,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
||||
id: '1-2',
|
||||
code: 'KD-SS-241024-15',
|
||||
date: '2024-10-24',
|
||||
client: '(주)강남건설',
|
||||
site: '강남 아파트 B동',
|
||||
locationCount: 7,
|
||||
subItems: [
|
||||
@@ -136,7 +134,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
||||
id: '2-1',
|
||||
code: 'SC-AP-241101-01',
|
||||
date: '2024-11-01',
|
||||
client: '서초개발(주)',
|
||||
site: '서초 오피스텔 본관',
|
||||
locationCount: 8,
|
||||
subItems: [
|
||||
@@ -150,7 +147,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
||||
id: '3-1',
|
||||
code: 'SP-CW-240801-01',
|
||||
date: '2024-08-01',
|
||||
client: '송파건설(주)',
|
||||
site: '송파 주상복합 A타워',
|
||||
locationCount: 10,
|
||||
subItems: [
|
||||
@@ -161,7 +157,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
||||
id: '3-2',
|
||||
code: 'SP-CW-240815-02',
|
||||
date: '2024-08-15',
|
||||
client: '송파건설(주)',
|
||||
site: '송파 주상복합 B타워',
|
||||
locationCount: 8,
|
||||
subItems: [],
|
||||
@@ -170,7 +165,6 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
||||
id: '3-3',
|
||||
code: 'SP-CW-240901-03',
|
||||
date: '2024-09-01',
|
||||
client: '송파건설(주)',
|
||||
site: '송파 주상복합 상가동',
|
||||
locationCount: 3,
|
||||
subItems: [],
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { Header } from './components/Header';
|
||||
import { Filters } from './components/Filters';
|
||||
import { ReportList } from './components/ReportList';
|
||||
import { RouteList } from './components/RouteList';
|
||||
import { DocumentList } from './components/DocumentList';
|
||||
// import { InspectionModal } from './components/InspectionModal';
|
||||
import { InspectionModal } from './components/InspectionModal';
|
||||
import { DayTabs } from './components/DayTabs';
|
||||
import { Day1ChecklistPanel } from './components/Day1ChecklistPanel';
|
||||
import { Day1DocumentSection } from './components/Day1DocumentSection';
|
||||
import { Day1DocumentViewer } from './components/Day1DocumentViewer';
|
||||
import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel';
|
||||
import { useDay1Audit } from './hooks/useDay1Audit';
|
||||
import { useDay2LotAudit } from './hooks/useDay2LotAudit';
|
||||
import { useChecklistTemplate } from './hooks/useChecklistTemplate';
|
||||
import { uploadTemplateDocument, getTemplateDocuments, deleteTemplateDocument } from './actions';
|
||||
import type { TemplateDocument } from './types';
|
||||
import { InspectionReport, RouteItem, Document, DocumentItem, ChecklistCategory } from './types';
|
||||
import {
|
||||
MOCK_REPORTS,
|
||||
MOCK_ROUTES_INITIAL,
|
||||
MOCK_DOCUMENTS,
|
||||
DEFAULT_DOCUMENTS,
|
||||
MOCK_DAY1_CATEGORIES,
|
||||
MOCK_DAY1_CHECK_ITEMS,
|
||||
MOCK_DAY1_STANDARD_DOCUMENTS,
|
||||
} from './mockData';
|
||||
|
||||
// 기본 설정값
|
||||
const DEFAULT_SETTINGS: AuditDisplaySettings = {
|
||||
@@ -36,112 +41,195 @@ export default function QualityInspectionPage() {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [displaySettings, setDisplaySettings] = useState<AuditDisplaySettings>(DEFAULT_SETTINGS);
|
||||
|
||||
// 1일차 커스텀 훅
|
||||
const {
|
||||
templateId,
|
||||
categories,
|
||||
day1Progress,
|
||||
selectedCheckItem,
|
||||
isSelectedItemCompleted,
|
||||
selectedSubItemId,
|
||||
handleSubItemSelect,
|
||||
handleSubItemToggle,
|
||||
handleConfirmComplete,
|
||||
isMock: day1IsMock,
|
||||
} = useDay1Audit();
|
||||
// 1일차 상태
|
||||
const [day1Categories, setDay1Categories] = useState<ChecklistCategory[]>(MOCK_DAY1_CATEGORIES);
|
||||
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
|
||||
|
||||
// 점검표 템플릿 관리 훅 (설정 모달용)
|
||||
const checklistTemplate = useChecklistTemplate();
|
||||
// 2일차(로트추적) 필터 상태
|
||||
const [selectedYear, setSelectedYear] = useState(2025);
|
||||
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
// 업로드된 파일 상태 (subItemId별)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<Record<string, TemplateDocument[]>>({});
|
||||
const [selectedUploadedFile, setSelectedUploadedFile] = useState<TemplateDocument | null>(null);
|
||||
// 2일차 선택 상태
|
||||
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
|
||||
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
|
||||
|
||||
// 선택된 항목 변경 시 파일 목록 로드 + 업로드 파일 선택 초기화
|
||||
useEffect(() => {
|
||||
setSelectedUploadedFile(null);
|
||||
if (!selectedSubItemId || !templateId) return;
|
||||
if (uploadedFiles[selectedSubItemId]) return; // 이미 로드됨
|
||||
// 2일차 루트 데이터 상태 (완료 토글용)
|
||||
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(MOCK_ROUTES_INITIAL);
|
||||
|
||||
getTemplateDocuments(templateId, selectedSubItemId).then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setUploadedFiles((prev) => ({ ...prev, [selectedSubItemId]: result.data! }));
|
||||
}
|
||||
// 모달 상태
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null);
|
||||
|
||||
// ===== 1일차 진행률 계산 =====
|
||||
const day1Progress = useMemo(() => {
|
||||
const total = day1Categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
|
||||
const completed = day1Categories.reduce(
|
||||
(sum, cat) => sum + cat.subItems.filter(item => item.isCompleted).length,
|
||||
0
|
||||
);
|
||||
return { completed, total };
|
||||
}, [day1Categories]);
|
||||
|
||||
// ===== 2일차 진행률 계산 (개소별 완료 기준) =====
|
||||
const day2Progress = useMemo(() => {
|
||||
let completed = 0;
|
||||
let total = 0;
|
||||
Object.values(routesData).forEach(routes => {
|
||||
routes.forEach(route => {
|
||||
route.subItems.forEach(item => {
|
||||
total++;
|
||||
if (item.isCompleted) completed++;
|
||||
});
|
||||
});
|
||||
});
|
||||
}, [selectedSubItemId, templateId]);
|
||||
return { completed, total };
|
||||
}, [routesData]);
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
const handleFileUpload = useCallback(async (subItemId: string, file: File): Promise<boolean> => {
|
||||
if (!templateId) {
|
||||
toast.error('점검표 템플릿이 로드되지 않았습니다.');
|
||||
return false;
|
||||
}
|
||||
const result = await uploadTemplateDocument(templateId, subItemId, file);
|
||||
if (!result.success) {
|
||||
toast.error(result.error || '파일 업로드에 실패했습니다.');
|
||||
return false;
|
||||
}
|
||||
// 업로드 성공 시 파일 목록에 추가
|
||||
if (result.data) {
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[subItemId]: [result.data!, ...(prev[subItemId] || [])],
|
||||
}));
|
||||
}
|
||||
return true;
|
||||
}, [templateId]);
|
||||
|
||||
// 파일 삭제 핸들러
|
||||
const handleFileDelete = useCallback(async (fileId: number, subItemId: string) => {
|
||||
const result = await deleteTemplateDocument(fileId);
|
||||
if (result.success) {
|
||||
setUploadedFiles((prev) => ({
|
||||
...prev,
|
||||
[subItemId]: (prev[subItemId] || []).filter((f) => f.id !== fileId),
|
||||
}));
|
||||
toast.success('파일이 삭제되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '파일 삭제에 실패했습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 2일차 커스텀 훅
|
||||
const {
|
||||
selectedYear,
|
||||
selectedQuarter,
|
||||
searchTerm,
|
||||
handleYearChange,
|
||||
handleQuarterChange,
|
||||
handleSearchChange,
|
||||
filteredReports,
|
||||
currentRoutes,
|
||||
currentDocuments,
|
||||
day2Progress,
|
||||
selectedReport,
|
||||
selectedRoute,
|
||||
handleReportSelect,
|
||||
handleRouteSelect,
|
||||
modalOpen,
|
||||
selectedDoc,
|
||||
selectedDocItem,
|
||||
handleViewDocument,
|
||||
setModalOpen,
|
||||
handleToggleItem,
|
||||
isMock: day2IsMock,
|
||||
} = useDay2LotAudit();
|
||||
|
||||
// 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션)
|
||||
// ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) =====
|
||||
const filteredDay1Categories = useMemo(() => {
|
||||
if (displaySettings.showCompletedItems) return categories;
|
||||
if (displaySettings.showCompletedItems) return day1Categories;
|
||||
|
||||
return categories.map(category => ({
|
||||
return day1Categories.map(category => ({
|
||||
...category,
|
||||
subItems: category.subItems.filter(item => !item.isCompleted),
|
||||
})).filter(category => category.subItems.length > 0);
|
||||
}, [categories, displaySettings.showCompletedItems]);
|
||||
}, [day1Categories, displaySettings.showCompletedItems]);
|
||||
|
||||
// ===== 1일차 핸들러 =====
|
||||
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
|
||||
setSelectedCategoryId(categoryId);
|
||||
setSelectedSubItemId(subItemId);
|
||||
setSelectedStandardDocId(null);
|
||||
}, []);
|
||||
|
||||
const handleSubItemToggle = useCallback((categoryId: string, subItemId: string, isCompleted: boolean) => {
|
||||
setDay1Categories(prev => prev.map(cat => {
|
||||
if (cat.id !== categoryId) return cat;
|
||||
return {
|
||||
...cat,
|
||||
subItems: cat.subItems.map(item => {
|
||||
if (item.id !== subItemId) return item;
|
||||
return { ...item, isCompleted };
|
||||
}),
|
||||
};
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleConfirmComplete = useCallback(() => {
|
||||
if (selectedCategoryId && selectedSubItemId) {
|
||||
handleSubItemToggle(selectedCategoryId, selectedSubItemId, true);
|
||||
}
|
||||
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
|
||||
|
||||
// 선택된 1일차 점검 항목
|
||||
const selectedCheckItem = useMemo(() => {
|
||||
if (!selectedSubItemId) return null;
|
||||
return MOCK_DAY1_CHECK_ITEMS.find(item => item.subItemId === selectedSubItemId) || null;
|
||||
}, [selectedSubItemId]);
|
||||
|
||||
// 선택된 표준 문서
|
||||
const selectedStandardDoc = useMemo(() => {
|
||||
if (!selectedStandardDocId || !selectedSubItemId) return null;
|
||||
const docs = MOCK_DAY1_STANDARD_DOCUMENTS[selectedSubItemId] || [];
|
||||
return docs.find(doc => doc.id === selectedStandardDocId) || null;
|
||||
}, [selectedStandardDocId, selectedSubItemId]);
|
||||
|
||||
// 선택된 항목의 완료 여부
|
||||
const isSelectedItemCompleted = useMemo(() => {
|
||||
if (!selectedSubItemId) return false;
|
||||
for (const cat of day1Categories) {
|
||||
const item = cat.subItems.find(item => item.id === selectedSubItemId);
|
||||
if (item) return item.isCompleted;
|
||||
}
|
||||
return false;
|
||||
}, [day1Categories, selectedSubItemId]);
|
||||
|
||||
// ===== 2일차(로트추적) 로직 =====
|
||||
const filteredReports = useMemo(() => {
|
||||
return MOCK_REPORTS.filter((report) => {
|
||||
if (report.year !== selectedYear) return false;
|
||||
if (selectedQuarter !== '전체') {
|
||||
const quarterNum = parseInt(selectedQuarter.replace('Q', ''));
|
||||
if (report.quarterNum !== quarterNum) return false;
|
||||
}
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
const matchesCode = report.code.toLowerCase().includes(term);
|
||||
const matchesSite = report.siteName.toLowerCase().includes(term);
|
||||
const matchesItem = report.item.toLowerCase().includes(term);
|
||||
if (!matchesCode && !matchesSite && !matchesItem) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [selectedYear, selectedQuarter, searchTerm]);
|
||||
|
||||
const currentRoutes = useMemo(() => {
|
||||
if (!selectedReport) return [];
|
||||
return routesData[selectedReport.id] || [];
|
||||
}, [selectedReport, routesData]);
|
||||
|
||||
const currentDocuments = useMemo(() => {
|
||||
if (!selectedRoute) return DEFAULT_DOCUMENTS;
|
||||
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
|
||||
}, [selectedRoute]);
|
||||
|
||||
const handleReportSelect = (report: InspectionReport) => {
|
||||
setSelectedReport(report);
|
||||
setSelectedRoute(null);
|
||||
};
|
||||
|
||||
const handleRouteSelect = (route: RouteItem) => {
|
||||
setSelectedRoute(route);
|
||||
};
|
||||
|
||||
const handleViewDocument = (doc: Document, item?: DocumentItem) => {
|
||||
setSelectedDoc(doc);
|
||||
setSelectedDocItem(item || null);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleYearChange = (year: number) => {
|
||||
setSelectedYear(year);
|
||||
setSelectedReport(null);
|
||||
setSelectedRoute(null);
|
||||
};
|
||||
|
||||
const handleQuarterChange = (quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
|
||||
setSelectedQuarter(quarter);
|
||||
setSelectedReport(null);
|
||||
setSelectedRoute(null);
|
||||
};
|
||||
|
||||
const handleSearchChange = (term: string) => {
|
||||
setSearchTerm(term);
|
||||
};
|
||||
|
||||
// ===== 2일차 개소별 완료 토글 =====
|
||||
const handleToggleItem = useCallback((routeId: string, itemId: string, isCompleted: boolean) => {
|
||||
setRoutesData(prev => {
|
||||
const newData = { ...prev };
|
||||
for (const reportId of Object.keys(newData)) {
|
||||
newData[reportId] = newData[reportId].map(route => {
|
||||
if (route.id !== routeId) return route;
|
||||
return {
|
||||
...route,
|
||||
subItems: route.subItems.map(item => {
|
||||
if (item.id !== itemId) return item;
|
||||
return { ...item, isCompleted };
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
return newData;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-auto">
|
||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-hidden">
|
||||
{/* 헤더 (설정 버튼 포함) */}
|
||||
<Header
|
||||
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
|
||||
@@ -195,9 +283,9 @@ export default function QualityInspectionPage() {
|
||||
|
||||
{activeDay === 1 ? (
|
||||
// ===== 기준/매뉴얼 심사 심사 =====
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-[500px]">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
|
||||
{/* 좌측: 점검표 항목 */}
|
||||
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
|
||||
? 'lg:col-span-3'
|
||||
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
|
||||
@@ -210,69 +298,59 @@ export default function QualityInspectionPage() {
|
||||
searchTerm={searchTerm}
|
||||
onSubItemSelect={handleSubItemSelect}
|
||||
onSubItemToggle={handleSubItemToggle}
|
||||
isMock={day1IsMock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 기준 문서화 */}
|
||||
{displaySettings.showDocumentSection && (
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
|
||||
}`}>
|
||||
<Day1DocumentSection
|
||||
checkItem={selectedCheckItem}
|
||||
selectedDocumentId={selectedStandardDocId}
|
||||
onDocumentSelect={setSelectedStandardDocId}
|
||||
onConfirmComplete={handleConfirmComplete}
|
||||
isCompleted={isSelectedItemCompleted}
|
||||
isMock={day1IsMock}
|
||||
onFileUpload={handleFileUpload}
|
||||
uploadedFiles={selectedSubItemId ? uploadedFiles[selectedSubItemId] || [] : []}
|
||||
onFileDelete={selectedSubItemId ? (fileId) => handleFileDelete(fileId, selectedSubItemId) : undefined}
|
||||
onFileSelect={(file) => {
|
||||
setSelectedUploadedFile(file);
|
||||
}}
|
||||
selectedFileId={selectedUploadedFile?.id ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측: 문서 뷰어 */}
|
||||
{displaySettings.showDocumentViewer && (
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
|
||||
}`}>
|
||||
<Day1DocumentViewer document={null} uploadedFile={selectedUploadedFile} isMock={day1IsMock} />
|
||||
<Day1DocumentViewer document={selectedStandardDoc} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// ===== 로트 추적 심사 심사 =====
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-[500px]">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<ReportList
|
||||
reports={filteredReports}
|
||||
selectedId={selectedReport?.id || null}
|
||||
onSelect={handleReportSelect}
|
||||
isMock={day2IsMock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<RouteList
|
||||
routes={currentRoutes}
|
||||
selectedId={selectedRoute?.id || null}
|
||||
onSelect={handleRouteSelect}
|
||||
onToggleItem={handleToggleItem}
|
||||
reportCode={selectedReport?.code || null}
|
||||
isMock={day2IsMock}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<DocumentList
|
||||
documents={currentDocuments}
|
||||
routeCode={selectedRoute?.code || null}
|
||||
onViewDocument={handleViewDocument}
|
||||
isMock={day2IsMock}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,25 +362,6 @@ export default function QualityInspectionPage() {
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
settings={displaySettings}
|
||||
onSettingsChange={setDisplaySettings}
|
||||
checklistManagement={{
|
||||
categories: checklistTemplate.editCategories,
|
||||
hasChanges: checklistTemplate.hasChanges,
|
||||
saving: checklistTemplate.saving,
|
||||
loading: checklistTemplate.loading,
|
||||
error: checklistTemplate.error,
|
||||
onAddCategory: checklistTemplate.addCategory,
|
||||
onUpdateCategoryTitle: checklistTemplate.updateCategoryTitle,
|
||||
onDeleteCategory: checklistTemplate.deleteCategory,
|
||||
onMoveCategoryUp: checklistTemplate.moveCategoryUp,
|
||||
onMoveCategoryDown: checklistTemplate.moveCategoryDown,
|
||||
onAddSubItem: checklistTemplate.addSubItem,
|
||||
onUpdateSubItemName: checklistTemplate.updateSubItemName,
|
||||
onDeleteSubItem: checklistTemplate.deleteSubItem,
|
||||
onMoveSubItemUp: checklistTemplate.moveSubItemUp,
|
||||
onMoveSubItemDown: checklistTemplate.moveSubItemDown,
|
||||
onSave: checklistTemplate.saveTemplate,
|
||||
onReset: checklistTemplate.resetToSaved,
|
||||
}}
|
||||
/>
|
||||
|
||||
<InspectionModal
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface RouteItem {
|
||||
id: string;
|
||||
code: string; // e.g., KD-SS-240924-19
|
||||
date: string; // 2024-09-24
|
||||
client: string; // 거래처(발주처)
|
||||
site: string; // 강남 아파트 A동
|
||||
locationCount: number;
|
||||
subItems: UnitInspection[];
|
||||
@@ -43,8 +42,6 @@ export interface DocumentItem {
|
||||
code?: string;
|
||||
// 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용)
|
||||
subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
|
||||
// 작업일지/중간검사에서 실제 WorkOrder 데이터 로딩용
|
||||
workOrderId?: number;
|
||||
}
|
||||
|
||||
// ===== 기준/매뉴얼 심사 심사 타입 =====
|
||||
@@ -95,28 +92,3 @@ export interface Day2Progress {
|
||||
completed: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
// 업로드된 템플릿 문서
|
||||
export interface TemplateDocument {
|
||||
id: number;
|
||||
fieldKey: string;
|
||||
displayName: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
uploadedBy?: string | null;
|
||||
createdAt?: string | null;
|
||||
}
|
||||
|
||||
// ===== 점검표 템플릿 관리 타입 =====
|
||||
|
||||
// 점검표 템플릿 (API 응답)
|
||||
export interface ChecklistTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
categories: ChecklistCategory[];
|
||||
options: Record<string, unknown> | null;
|
||||
fileCounts: Record<string, number>;
|
||||
updatedAt: string | null;
|
||||
updatedBy: string | null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Plus,
|
||||
Users,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Bell,
|
||||
} from "lucide-react";
|
||||
@@ -57,13 +58,13 @@ export default function CustomerAccountManagementPage() {
|
||||
const {
|
||||
clients,
|
||||
pagination,
|
||||
isLoading: _isLoading,
|
||||
isLoading,
|
||||
fetchClients,
|
||||
deleteClient: deleteClientApi,
|
||||
} = useClientList();
|
||||
|
||||
const [searchTerm, _setSearchTerm] = useState("");
|
||||
const [filterType, _setFilterType] = useState("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [filterType, setFilterType] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
@@ -175,7 +176,7 @@ export default function CustomerAccountManagementPage() {
|
||||
const paginatedClients = filteredClients;
|
||||
|
||||
// 모바일용 인피니티 스크롤 데이터
|
||||
const _mobileClients = filteredClients.slice(0, mobileDisplayCount);
|
||||
const mobileClients = filteredClients.slice(0, mobileDisplayCount);
|
||||
|
||||
// Intersection Observer를 이용한 인피니티 스크롤
|
||||
useEffect(() => {
|
||||
@@ -261,12 +262,12 @@ export default function CustomerAccountManagementPage() {
|
||||
// 테이블 컬럼 정의 (Hooks 순서 보장을 위해 조건부 return 전에 정의)
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: "rowNumber", label: "번호", className: "px-4" },
|
||||
{ key: "code", label: "코드", className: "px-4", sortable: true, copyable: true },
|
||||
{ key: "code", label: "코드", className: "px-4", sortable: true },
|
||||
{ key: "clientType", label: "구분", className: "px-4", sortable: true },
|
||||
{ key: "name", label: "거래처명", className: "px-4", sortable: true, copyable: true },
|
||||
{ key: "representative", label: "대표자", className: "px-4", sortable: true, copyable: true },
|
||||
{ key: "managerName", label: "담당자", className: "px-4", sortable: true, copyable: true },
|
||||
{ key: "phone", label: "전화번호", className: "px-4", sortable: true, copyable: true },
|
||||
{ key: "name", label: "거래처명", className: "px-4", sortable: true },
|
||||
{ key: "representative", label: "대표자", className: "px-4", sortable: true },
|
||||
{ key: "manager", label: "담당자", className: "px-4", sortable: true },
|
||||
{ key: "phone", label: "전화번호", className: "px-4", sortable: true },
|
||||
], []);
|
||||
|
||||
// 핸들러 - 페이지 기반 네비게이션
|
||||
@@ -274,7 +275,7 @@ export default function CustomerAccountManagementPage() {
|
||||
router.push("/sales/client-management-sales-admin?mode=new");
|
||||
};
|
||||
|
||||
const _handleEdit = (customer: Client) => {
|
||||
const handleEdit = (customer: Client) => {
|
||||
router.push(`/sales/client-management-sales-admin/${customer.id}?mode=edit`);
|
||||
};
|
||||
|
||||
@@ -282,7 +283,7 @@ export default function CustomerAccountManagementPage() {
|
||||
router.push(`/sales/client-management-sales-admin/${customer.id}?mode=view`);
|
||||
};
|
||||
|
||||
const _handleDelete = (customerId: string) => {
|
||||
const handleDelete = (customerId: string) => {
|
||||
setDeleteTargetId(customerId);
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
@@ -303,7 +304,7 @@ export default function CustomerAccountManagementPage() {
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const _toggleSelection = (id: string) => {
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
@@ -313,7 +314,7 @@ export default function CustomerAccountManagementPage() {
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const _toggleSelectAll = () => {
|
||||
const toggleSelectAll = () => {
|
||||
if (
|
||||
selectedItems.size === paginatedClients.length &&
|
||||
paginatedClients.length > 0
|
||||
@@ -325,7 +326,7 @@ export default function CustomerAccountManagementPage() {
|
||||
};
|
||||
|
||||
// 일괄 삭제
|
||||
const _handleBulkDelete = () => {
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.error("삭제할 항목을 선택해주세요");
|
||||
return;
|
||||
|
||||
@@ -27,6 +27,9 @@ import {
|
||||
PenLine,
|
||||
Factory,
|
||||
XCircle,
|
||||
FileSpreadsheet,
|
||||
FileCheck,
|
||||
ClipboardList,
|
||||
Eye,
|
||||
CheckCircle2,
|
||||
RotateCcw,
|
||||
@@ -272,12 +275,12 @@ export default function OrderDetailPage() {
|
||||
setIsProductionSuccessDialogOpen(false);
|
||||
};
|
||||
|
||||
const _handleViewProductionOrder = () => {
|
||||
const handleViewProductionOrder = () => {
|
||||
// 생산지시 목록 페이지로 이동 (수주관리 내부)
|
||||
router.push(`/sales/order-management-sales/production-orders`);
|
||||
};
|
||||
|
||||
const _handleCancel = () => {
|
||||
const handleCancel = () => {
|
||||
setCancelReason("");
|
||||
setCancelDetail("");
|
||||
setIsCancelDialogOpen(true);
|
||||
@@ -429,7 +432,7 @@ export default function OrderDetailPage() {
|
||||
};
|
||||
|
||||
// 수주 삭제
|
||||
const _handleDelete = () => {
|
||||
const handleDelete = () => {
|
||||
setIsDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -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, copyable: true },
|
||||
{ key: "siteName", label: "현장명", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "expectedShipDate", label: "출고예정일", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "orderDate", label: "수주일", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "client", label: "수주처", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "productName", label: "제품명", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "receiver", label: "수신자", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "receiverAddress", label: "수신주소", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "receiverPlace", label: "수신처", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "deliveryMethod", label: "배송", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "manager", label: "담당자", className: "px-2", sortable: true, copyable: true },
|
||||
{ key: "frameCount", label: "틀수", className: "px-2 text-center", sortable: true, copyable: true },
|
||||
{ key: "lotNumber", label: "로트번호", className: "px-2", sortable: true },
|
||||
{ key: "siteName", label: "현장명", className: "px-2", sortable: true },
|
||||
{ key: "expectedShipDate", label: "출고예정일", className: "px-2", sortable: true },
|
||||
{ key: "orderDate", label: "수주일", className: "px-2", sortable: true },
|
||||
{ key: "client", label: "수주처", className: "px-2", sortable: true },
|
||||
{ key: "productName", label: "제품명", className: "px-2", sortable: true },
|
||||
{ key: "receiver", label: "수신자", className: "px-2", sortable: true },
|
||||
{ key: "receiverAddress", label: "수신주소", className: "px-2", sortable: true },
|
||||
{ key: "receiverPlace", label: "수신처", className: "px-2", sortable: true },
|
||||
{ key: "deliveryMethod", label: "배송", className: "px-2", sortable: true },
|
||||
{ key: "manager", label: "담당자", className: "px-2", sortable: true },
|
||||
{ key: "frameCount", label: "틀수", className: "px-2 text-center", sortable: true },
|
||||
{ key: "status", label: "상태", className: "px-2", sortable: true },
|
||||
{ key: "remarks", label: "비고", className: "px-2", copyable: true },
|
||||
{ key: "remarks", label: "비고", className: "px-2" },
|
||||
], []);
|
||||
|
||||
// 테이블 행 렌더링 (16개 컬럼: 체크박스, 번호, 로트번호, 현장명, 출고예정일, 접수일, 수주처, 제품명, 수신자, 수신주소, 수신처, 배송, 담당자, 틀수, 상태, 비고)
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
Circle,
|
||||
Activity,
|
||||
Play,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
@@ -48,16 +47,143 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { toast } from "sonner";
|
||||
import { ServerErrorPage } from "@/components/common/ServerErrorPage";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { getProductionOrderDetail } from "@/components/production/ProductionOrders/actions";
|
||||
import { createProductionOrder } from "@/components/orders/actions";
|
||||
import type {
|
||||
ProductionOrderDetail,
|
||||
ProductionStatus,
|
||||
ProductionWorkOrder,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus = "waiting" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 상태 타입
|
||||
type WorkOrderStatus = "pending" | "in_progress" | "completed";
|
||||
|
||||
// 작업지시 데이터 타입
|
||||
interface WorkOrder {
|
||||
id: string;
|
||||
workOrderNumber: string; // KD-WO-XXXXXX-XX
|
||||
process: string; // 공정명
|
||||
quantity: number;
|
||||
status: WorkOrderStatus;
|
||||
assignee: string;
|
||||
}
|
||||
|
||||
// 생산지시 상세 데이터 타입
|
||||
interface ProductionOrderDetail {
|
||||
id: string;
|
||||
productionOrderNumber: string;
|
||||
orderNumber: string;
|
||||
productionOrderDate: string;
|
||||
dueDate: string;
|
||||
quantity: number;
|
||||
status: ProductionOrderStatus;
|
||||
client: string;
|
||||
siteName: string;
|
||||
productType: string;
|
||||
pendingWorkOrderCount: number; // 생성 예정 작업지시 수
|
||||
workOrders: WorkOrder[];
|
||||
}
|
||||
|
||||
// 샘플 생산지시 상세 데이터
|
||||
const SAMPLE_PRODUCTION_ORDER_DETAILS: Record<string, ProductionOrderDetail> = {
|
||||
"PO-001": {
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-15",
|
||||
quantity: 2,
|
||||
status: "completed", // 생산완료 상태 - 목록 버튼만 표시
|
||||
client: "호반건설(주)",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0, // 작업지시 이미 생성됨
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-001",
|
||||
workOrderNumber: "KD-WO-251217-07",
|
||||
process: "재단",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-002",
|
||||
workOrderNumber: "KD-WO-251217-08",
|
||||
process: "조립",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-003",
|
||||
workOrderNumber: "KD-WO-251217-09",
|
||||
process: "검수",
|
||||
quantity: 2,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
"PO-002": {
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 10,
|
||||
status: "waiting",
|
||||
client: "태영건설(주)",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 5, // 생성 예정 작업지시 5개 (일괄생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-003": {
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
productionOrderDate: "2025-12-22",
|
||||
dueDate: "2026-02-10",
|
||||
quantity: 1,
|
||||
status: "waiting",
|
||||
client: "롯데건설(주)",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 1, // 생성 예정 작업지시 1개 (단일 생성)
|
||||
workOrders: [],
|
||||
},
|
||||
"PO-004": {
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
productionOrderDate: "2025-12-20",
|
||||
dueDate: "2026-02-03",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
client: "현대건설(주)",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
productType: "",
|
||||
pendingWorkOrderCount: 0,
|
||||
workOrders: [
|
||||
{
|
||||
id: "WO-004",
|
||||
workOrderNumber: "KD-WO-251220-01",
|
||||
process: "재단",
|
||||
quantity: 3,
|
||||
status: "completed",
|
||||
assignee: "-",
|
||||
},
|
||||
{
|
||||
id: "WO-005",
|
||||
workOrderNumber: "KD-WO-251220-02",
|
||||
process: "조립",
|
||||
quantity: 3,
|
||||
status: "in_progress",
|
||||
assignee: "-",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// 공정 진행 현황 컴포넌트
|
||||
function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] }) {
|
||||
function ProcessProgress({ workOrders }: { workOrders: WorkOrder[] }) {
|
||||
if (workOrders.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
@@ -76,9 +202,7 @@ function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] })
|
||||
);
|
||||
}
|
||||
|
||||
const completedCount = workOrders.filter(
|
||||
(w) => w.status === "completed" || w.status === "shipped"
|
||||
).length;
|
||||
const completedCount = workOrders.filter((w) => w.status === "completed").length;
|
||||
const totalCount = workOrders.length;
|
||||
const progressPercent = Math.round((completedCount / totalCount) * 100);
|
||||
|
||||
@@ -113,27 +237,25 @@ function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] })
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`flex items-center justify-center w-8 h-8 rounded-full ${
|
||||
wo.status === "completed" || wo.status === "shipped"
|
||||
wo.status === "completed"
|
||||
? "bg-green-500 text-white"
|
||||
: wo.status === "in_progress"
|
||||
? "bg-blue-500 text-white"
|
||||
: "bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{wo.status === "completed" || wo.status === "shipped" ? (
|
||||
{wo.status === "completed" ? (
|
||||
<CheckCircle2 className="h-4 w-4" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{wo.processName}</span>
|
||||
<span className="text-xs text-muted-foreground">{wo.process}</span>
|
||||
</div>
|
||||
{index < workOrders.length - 1 && (
|
||||
<div
|
||||
className={`w-12 h-0.5 mx-1 ${
|
||||
wo.status === "completed" || wo.status === "shipped"
|
||||
? "bg-green-500"
|
||||
: "bg-gray-200"
|
||||
wo.status === "completed" ? "bg-green-500" : "bg-gray-200"
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
@@ -147,13 +269,13 @@ function ProcessProgress({ workOrders }: { workOrders: ProductionWorkOrder[] })
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionStatus) {
|
||||
const config: Record<ProductionStatus, { label: string; className: string }> = {
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
const config: Record<ProductionOrderStatus, { label: string; className: string }> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_production: {
|
||||
in_progress: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
@@ -167,16 +289,22 @@ function getStatusBadge(status: ProductionStatus) {
|
||||
}
|
||||
|
||||
// 작업지시 상태 배지 헬퍼
|
||||
function getWorkOrderStatusBadge(status: string) {
|
||||
const config: Record<string, { label: string; className: string }> = {
|
||||
unassigned: { label: "미배정", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
pending: { label: "대기", className: "bg-gray-100 text-gray-700 border-gray-200" },
|
||||
waiting: { label: "준비중", className: "bg-yellow-100 text-yellow-700 border-yellow-200" },
|
||||
in_progress: { label: "작업중", className: "bg-blue-100 text-blue-700 border-blue-200" },
|
||||
completed: { label: "완료", className: "bg-green-100 text-green-700 border-green-200" },
|
||||
shipped: { label: "출하", className: "bg-purple-100 text-purple-700 border-purple-200" },
|
||||
function getWorkOrderStatusBadge(status: WorkOrderStatus) {
|
||||
const config: Record<WorkOrderStatus, { label: string; className: string }> = {
|
||||
pending: {
|
||||
label: "대기",
|
||||
className: "bg-gray-100 text-gray-700 border-gray-200",
|
||||
},
|
||||
in_progress: {
|
||||
label: "작업중",
|
||||
className: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
},
|
||||
completed: {
|
||||
label: "완료",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
};
|
||||
const c = config[status] || { label: status, className: "bg-gray-100 text-gray-700 border-gray-200" };
|
||||
const c = config[status];
|
||||
return <BadgeSm className={c.className}>{c.label}</BadgeSm>;
|
||||
}
|
||||
|
||||
@@ -190,33 +318,99 @@ function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// 샘플 공정 목록 (작업지시 생성 팝업에 표시용)
|
||||
const SAMPLE_PROCESSES = [
|
||||
{ id: "P1", name: "1.1 백판필름", quantity: 10 },
|
||||
{ id: "P2", name: "2. 하안마감재", quantity: 10 },
|
||||
{ id: "P3", name: "3.1 케이스", quantity: 10 },
|
||||
{ id: "P4", name: "4. 연기단자", quantity: 10 },
|
||||
{ id: "P5", name: "5. 가이드레일 하부브라켓", quantity: 10 },
|
||||
];
|
||||
|
||||
// BOM 품목 타입
|
||||
interface BomItem {
|
||||
id: string;
|
||||
itemCode: string;
|
||||
itemName: string;
|
||||
spec: string;
|
||||
lotNo: string;
|
||||
requiredQty: number;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
// BOM 공정 분류 타입
|
||||
interface BomProcessGroup {
|
||||
processName: string;
|
||||
sizeSpec?: string;
|
||||
items: BomItem[];
|
||||
}
|
||||
|
||||
// BOM 품목별 공정 분류 목데이터
|
||||
const SAMPLE_BOM_PROCESS_GROUPS: BomProcessGroup[] = [
|
||||
{
|
||||
processName: "1.1 백판필름",
|
||||
sizeSpec: "[20-70]",
|
||||
items: [
|
||||
{ id: "B1", itemCode: "①", itemName: "아연판", spec: "EGI.6T", lotNo: "LOT-M-2024-001", requiredQty: 4300, qty: 8 },
|
||||
{ id: "B2", itemCode: "②", itemName: "가이드레일", spec: "EGI.6T", lotNo: "LOT-M-2024-002", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B3", itemCode: "③", itemName: "C형", spec: "EGI.6ST", lotNo: "LOT-M-2024-003", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B4", itemCode: "④", itemName: "D형", spec: "EGI.6T", lotNo: "LOT-M-2024-004", requiredQty: 4300, qty: 4 },
|
||||
{ id: "B5", itemCode: "⑤", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-005", requiredQty: 4300, qty: 2 },
|
||||
{ id: "B6", itemCode: "⑥", itemName: "R/RBASE", spec: "EGI.5ST", lotNo: "LOT-M-2024-006", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "2. 하안마감재",
|
||||
sizeSpec: "[60-40]",
|
||||
items: [
|
||||
{ id: "B7", itemCode: "①", itemName: "하안마감재", spec: "EGI.5T", lotNo: "LOT-M-2024-007", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B8", itemCode: "②", itemName: "하안보강재판", spec: "EGI.5T", lotNo: "LOT-M-2024-009", requiredQty: 3000, qty: 4 },
|
||||
{ id: "B9", itemCode: "③", itemName: "하안보강멈틀", spec: "EGI.1T", lotNo: "LOT-M-2024-010", requiredQty: 0, qty: 2 },
|
||||
{ id: "B10", itemCode: "④", itemName: "행거마감재", spec: "SUS1.2T", lotNo: "LOT-M-2024-008", requiredQty: 3000, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "3.1 케이스",
|
||||
sizeSpec: "[500*330]",
|
||||
items: [
|
||||
{ id: "B11", itemCode: "①", itemName: "전판", spec: "EGI.5ST", lotNo: "LOT-M-2024-011", requiredQty: 0, qty: 2 },
|
||||
{ id: "B12", itemCode: "②", itemName: "전멈틀", spec: "EGI.5T", lotNo: "LOT-M-2024-012", requiredQty: 0, qty: 4 },
|
||||
{ id: "B13", itemCode: "③⑤", itemName: "타부멈틀", spec: "", lotNo: "LOT-M-2024-013", requiredQty: 0, qty: 2 },
|
||||
{ id: "B14", itemCode: "④", itemName: "좌우판-HW", spec: "", lotNo: "LOT-M-2024-014", requiredQty: 0, qty: 2 },
|
||||
{ id: "B15", itemCode: "⑥", itemName: "상부판", spec: "", lotNo: "LOT-M-2024-015", requiredQty: 1220, qty: 5 },
|
||||
{ id: "B16", itemCode: "⑦", itemName: "측판(전산판)", spec: "", lotNo: "LOT-M-2024-016", requiredQty: 0, qty: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
processName: "4. 연기단자",
|
||||
sizeSpec: "",
|
||||
items: [
|
||||
{ id: "B17", itemCode: "-", itemName: "레일홀 (W50)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-017", requiredQty: 0, qty: 4 },
|
||||
{ id: "B18", itemCode: "-", itemName: "카이드홀 (W60)", spec: "EGI.8T + 화이버 금사", lotNo: "LOT-M-2024-018", requiredQty: 0, qty: 4 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function ProductionOrderDetailPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const orderId = params.id as string;
|
||||
const productionOrderId = params.id as string;
|
||||
|
||||
const [detail, setDetail] = useState<ProductionOrderDetail | null>(null);
|
||||
const [productionOrder, setProductionOrder] = useState<ProductionOrderDetail | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateWorkOrderDialogOpen, setIsCreateWorkOrderDialogOpen] = useState(false);
|
||||
const [isSuccessDialogOpen, setIsSuccessDialogOpen] = useState(false);
|
||||
const [createdWorkOrders, setCreatedWorkOrders] = useState<string[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [bomOpen, setBomOpen] = useState(false);
|
||||
|
||||
// 데이터 로드
|
||||
const loadDetail = async () => {
|
||||
setLoading(true);
|
||||
const result = await getProductionOrderDetail(orderId);
|
||||
if (result.success && result.data) {
|
||||
setDetail(result.data);
|
||||
} else {
|
||||
setDetail(null);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail();
|
||||
}, [orderId]);
|
||||
setTimeout(() => {
|
||||
const found = SAMPLE_PRODUCTION_ORDER_DETAILS[productionOrderId];
|
||||
setProductionOrder(found || null);
|
||||
setLoading(false);
|
||||
}, 300);
|
||||
}, [productionOrderId]);
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales/production-orders");
|
||||
@@ -229,13 +423,19 @@ export default function ProductionOrderDetailPage() {
|
||||
const handleConfirmCreateWorkOrder = async () => {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const result = await createProductionOrder(orderId);
|
||||
if (result.success) {
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
} else {
|
||||
toast.error(result.error || "작업지시 생성에 실패했습니다.");
|
||||
}
|
||||
// API 호출 시뮬레이션
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// 생성된 작업지시서 목록 (실제로는 API 응답에서 받음)
|
||||
const workOrderCount = productionOrder?.pendingWorkOrderCount || 0;
|
||||
const created = Array.from({ length: workOrderCount }, (_, i) =>
|
||||
`KD-WO-251223-${String(i + 1).padStart(2, "0")}`
|
||||
);
|
||||
setCreatedWorkOrders(created);
|
||||
|
||||
// 확인 팝업 닫고 성공 팝업 열기
|
||||
setIsCreateWorkOrderDialogOpen(false);
|
||||
setIsSuccessDialogOpen(true);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -257,7 +457,7 @@ export default function ProductionOrderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
if (!productionOrder) {
|
||||
return (
|
||||
<ServerErrorPage
|
||||
title="생산지시 정보를 불러올 수 없습니다"
|
||||
@@ -268,9 +468,6 @@ export default function ProductionOrderDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const hasWorkOrders = detail.workOrders.length > 0;
|
||||
const canCreateWorkOrders = detail.productionStatus === "waiting" && !hasWorkOrders;
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 헤더 */}
|
||||
@@ -279,9 +476,9 @@ export default function ProductionOrderDetailPage() {
|
||||
<div className="flex items-center gap-3">
|
||||
<span>생산지시 상세</span>
|
||||
<code className="text-sm font-mono bg-blue-50 text-blue-700 px-2 py-1 rounded">
|
||||
{detail.orderNumber}
|
||||
{productionOrder.productionOrderNumber}
|
||||
</code>
|
||||
{getStatusBadge(detail.productionStatus)}
|
||||
{getStatusBadge(productionOrder.status)}
|
||||
</div>
|
||||
}
|
||||
icon={Factory}
|
||||
@@ -291,7 +488,10 @@ export default function ProductionOrderDetailPage() {
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
{canCreateWorkOrders && (
|
||||
{/* 작업지시 생성 버튼 - 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<ClipboardList className="h-4 w-4 mr-2" />
|
||||
작업지시 생성
|
||||
@@ -303,7 +503,7 @@ export default function ProductionOrderDetailPage() {
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 공정 진행 현황 */}
|
||||
<ProcessProgress workOrders={detail.workOrders} />
|
||||
<ProcessProgress workOrders={productionOrder.workOrders} />
|
||||
|
||||
{/* 기본 정보 & 거래처/현장 정보 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -314,10 +514,11 @@ export default function ProductionOrderDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="수주번호" value={detail.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={detail.productionOrderedAt} />
|
||||
<InfoItem label="납기일" value={detail.deliveryDate} />
|
||||
<InfoItem label="개소" value={`${formatNumber(detail.nodeCount)}개소`} />
|
||||
<InfoItem label="생산지시번호" value={productionOrder.productionOrderNumber} />
|
||||
<InfoItem label="수주번호" value={productionOrder.orderNumber} />
|
||||
<InfoItem label="생산지시일" value={productionOrder.productionOrderDate} />
|
||||
<InfoItem label="납기일" value={productionOrder.dueDate} />
|
||||
<InfoItem label="수량" value={`${productionOrder.quantity}개`} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -329,108 +530,112 @@ export default function ProductionOrderDetailPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoItem label="거래처" value={detail.clientName} />
|
||||
<InfoItem label="현장명" value={detail.siteName} />
|
||||
<InfoItem label="거래처" value={productionOrder.client} />
|
||||
<InfoItem label="현장명" value={productionOrder.siteName} />
|
||||
<InfoItem label="제품유형" value={productionOrder.productType} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* BOM 품목별 공정 분류 (접이식) */}
|
||||
{detail.bomProcessGroups.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader
|
||||
className="cursor-pointer select-none"
|
||||
onClick={() => setBomOpen((prev) => !prev)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-base">
|
||||
BOM 품목별 공정 분류
|
||||
<span className="ml-2 text-sm font-normal text-muted-foreground">
|
||||
({detail.bomProcessGroups.length}개 공정)
|
||||
</span>
|
||||
</CardTitle>
|
||||
<ChevronDown
|
||||
className={`h-5 w-5 text-muted-foreground transition-transform ${
|
||||
bomOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{bomOpen && (
|
||||
<CardContent className="space-y-6 pt-0">
|
||||
{detail.bomProcessGroups.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
<h4 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Badge variant="outline">{group.processName}</Badge>
|
||||
<span className="text-muted-foreground font-normal text-xs">
|
||||
{group.items.length}건
|
||||
</span>
|
||||
</h4>
|
||||
{/* BOM 품목별 공정 분류 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">BOM 품목별 공정 분류</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* 절곡 부품 전개도 정보 헤더 */}
|
||||
<p className="text-sm font-medium text-muted-foreground border-b pb-2">
|
||||
절곡 부품 전개도 정보
|
||||
</p>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead>품목코드</TableHead>
|
||||
<TableHead>품목명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead className="text-center">단위</TableHead>
|
||||
<TableHead className="text-right">수량</TableHead>
|
||||
<TableHead className="text-right">단가</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
<TableHead>개소</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item, idx) => (
|
||||
<TableRow key={`${item.id}-${idx}`}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.itemCode}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.unit || "-"}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.quantity)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.unitPrice)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(item.totalPrice)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-xs">{item.nodeName || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
{/* 공정별 테이블 */}
|
||||
{SAMPLE_BOM_PROCESS_GROUPS.map((group) => (
|
||||
<div key={group.processName} className="space-y-2">
|
||||
{/* 공정명 헤더 */}
|
||||
<h4 className="text-sm font-semibold">
|
||||
{group.processName}
|
||||
{group.sizeSpec && (
|
||||
<span className="ml-2 text-muted-foreground font-normal">
|
||||
{group.sizeSpec}
|
||||
</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{/* BOM 품목 테이블 */}
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="w-[60px] text-center">항목코드</TableHead>
|
||||
<TableHead>세부품명</TableHead>
|
||||
<TableHead>규격</TableHead>
|
||||
<TableHead>LOT NO</TableHead>
|
||||
<TableHead className="text-right">필요수량</TableHead>
|
||||
<TableHead className="text-center w-[60px]">수량</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{group.items.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{item.itemCode}
|
||||
</TableCell>
|
||||
<TableCell>{item.itemName}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{item.spec || "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.lotNo}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{item.requiredQty > 0 ? formatNumber(item.requiredQty) : "-"}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">{item.qty}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 합계 정보 */}
|
||||
<div className="flex justify-between items-center pt-4 border-t text-sm">
|
||||
<span className="text-muted-foreground">총 부품 종류: 18개</span>
|
||||
<span className="text-muted-foreground">총 중량: 25.8 kg</span>
|
||||
<span className="text-muted-foreground">비고: VT칼 작업 완료 후 절곡 진행</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 작업지시서 목록 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">작업지시서 목록</CardTitle>
|
||||
{canCreateWorkOrders && (
|
||||
{/* 버튼 조건: 완료 아님 + 작업지시 없음 + 생성 예정 있음 */}
|
||||
{productionOrder.status !== "completed" &&
|
||||
productionOrder.workOrders.length === 0 &&
|
||||
productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<Button onClick={handleCreateWorkOrder}>
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
작업지시 생성
|
||||
{productionOrder.pendingWorkOrderCount > 1
|
||||
? "작업지시 일괄생성"
|
||||
: "작업지시 생성"}
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!hasWorkOrders ? (
|
||||
{productionOrder.workOrders.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<ClipboardList className="h-12 w-12 text-gray-300" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
아직 작업지시서가 생성되지 않았습니다.
|
||||
</p>
|
||||
{canCreateWorkOrders && (
|
||||
{productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
위 버튼을 클릭하여 BOM 기반 작업지시서를 자동 생성하세요.
|
||||
</p>
|
||||
@@ -444,23 +649,23 @@ export default function ProductionOrderDetailPage() {
|
||||
<TableRow>
|
||||
<TableHead>작업지시번호</TableHead>
|
||||
<TableHead>공정</TableHead>
|
||||
<TableHead className="text-center">개소</TableHead>
|
||||
<TableHead className="text-center">수량</TableHead>
|
||||
<TableHead>상태</TableHead>
|
||||
<TableHead>담당자</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detail.workOrders.map((wo) => (
|
||||
{productionOrder.workOrders.map((wo) => (
|
||||
<TableRow key={wo.id}>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{wo.workOrderNo}
|
||||
{wo.workOrderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>{wo.processName}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개소</TableCell>
|
||||
<TableCell>{wo.process}</TableCell>
|
||||
<TableCell className="text-center">{wo.quantity}개</TableCell>
|
||||
<TableCell>{getWorkOrderStatusBadge(wo.status)}</TableCell>
|
||||
<TableCell>{wo.assignees.length > 0 ? wo.assignees.join(", ") : "-"}</TableCell>
|
||||
<TableCell>{wo.assignee}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
@@ -471,7 +676,7 @@ export default function ProductionOrderDetailPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 작업지시 생성 확인 다이얼로그 */}
|
||||
{/* 팝업1: 작업지시 생성 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
open={isCreateWorkOrderDialogOpen}
|
||||
onOpenChange={setIsCreateWorkOrderDialogOpen}
|
||||
@@ -480,10 +685,19 @@ export default function ProductionOrderDetailPage() {
|
||||
description={
|
||||
<div className="space-y-4 pt-2">
|
||||
<p className="font-medium text-foreground">
|
||||
이 수주에 대한 작업지시서를 자동 생성합니다.
|
||||
다음 공정에 대한 작업지시서가 생성됩니다:
|
||||
</p>
|
||||
{productionOrder?.pendingWorkOrderCount && productionOrder.pendingWorkOrderCount > 0 && (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{SAMPLE_PROCESSES.slice(0, productionOrder.pendingWorkOrderCount).map((process) => (
|
||||
<li key={process.id} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-gray-400 rounded-full" />
|
||||
{process.name} ({process.quantity}개)
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<p className="text-muted-foreground">
|
||||
BOM 기반으로 공정별 작업지시서가 생성됩니다.
|
||||
생성된 작업지시서는 생산팀에서 확인하고 작업을 진행할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
@@ -492,7 +706,7 @@ export default function ProductionOrderDetailPage() {
|
||||
loading={isCreating}
|
||||
/>
|
||||
|
||||
{/* 작업지시 생성 성공 다이얼로그 */}
|
||||
{/* 팝업2: 작업지시 생성 성공 다이얼로그 */}
|
||||
<AlertDialog open={isSuccessDialogOpen} onOpenChange={setIsSuccessDialogOpen}>
|
||||
<AlertDialogContent className="max-w-md">
|
||||
<AlertDialogHeader>
|
||||
@@ -502,9 +716,24 @@ export default function ProductionOrderDetailPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
||||
<span className="font-medium text-foreground">
|
||||
작업지시서가 자동 생성되었습니다.
|
||||
{createdWorkOrders.length}개의 작업지시서가 공정별로 자동 생성되었습니다.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-2">생성된 작업지시서:</p>
|
||||
{createdWorkOrders.length > 0 ? (
|
||||
<ul className="space-y-1 text-sm text-muted-foreground pl-4">
|
||||
{createdWorkOrders.map((wo, idx) => (
|
||||
<li key={wo} className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 bg-green-500 rounded-full" />
|
||||
{wo}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">-</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
작업지시 관리 페이지로 이동합니다.
|
||||
</p>
|
||||
@@ -520,4 +749,4 @@ export default function ProductionOrderDetailPage() {
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,20 +4,24 @@
|
||||
* 생산지시 목록 페이지
|
||||
*
|
||||
* - 수주관리 > 생산지시 보기에서 접근
|
||||
* - 진행 단계 바 (Order 상태 기반 동적)
|
||||
* - 진행 단계 바
|
||||
* - 필터 탭: 전체, 생산대기, 생산중, 생산완료 (TabChip 사용)
|
||||
* - IntegratedListTemplateV2 템플릿 적용
|
||||
* - 서버사이드 페이지네이션
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { DeleteConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import {
|
||||
TableRow,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
@@ -25,6 +29,7 @@ import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
UniversalListPage,
|
||||
@@ -34,63 +39,136 @@ import {
|
||||
} from "@/components/templates/UniversalListPage";
|
||||
import { BadgeSm } from "@/components/atoms/BadgeSm";
|
||||
import { ListMobileCard, InfoField } from "@/components/organisms/MobileCard";
|
||||
import {
|
||||
getProductionOrders,
|
||||
getProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/actions";
|
||||
import type {
|
||||
ProductionOrder,
|
||||
ProductionStatus,
|
||||
ProductionOrderStats,
|
||||
} from "@/components/production/ProductionOrders/types";
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// 생산지시 상태 타입
|
||||
type ProductionOrderStatus =
|
||||
| "waiting" // 생산대기
|
||||
| "in_progress" // 생산중
|
||||
| "completed"; // 생산완료
|
||||
|
||||
// 생산지시 데이터 타입
|
||||
interface ProductionOrder {
|
||||
id: string;
|
||||
productionOrderNumber: string; // PO-KD-TS-XXXXXX-XX
|
||||
orderNumber: string; // KD-TS-XXXXXX-XX
|
||||
siteName: string;
|
||||
client: string;
|
||||
quantity: number;
|
||||
dueDate: string;
|
||||
productionOrderDate: string;
|
||||
status: ProductionOrderStatus;
|
||||
workOrderCount: number;
|
||||
}
|
||||
|
||||
// 샘플 생산지시 데이터
|
||||
const SAMPLE_PRODUCTION_ORDERS: ProductionOrder[] = [
|
||||
{
|
||||
id: "PO-001",
|
||||
productionOrderNumber: "PO-KD-TS-251217-07",
|
||||
orderNumber: "KD-TS-251217-07",
|
||||
siteName: "씨밋 광교 센트럴시티",
|
||||
client: "호반건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-02-15",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-002",
|
||||
productionOrderNumber: "PO-KD-TS-251217-09",
|
||||
orderNumber: "KD-TS-251217-09",
|
||||
siteName: "데시앙 동탄 파크뷰",
|
||||
client: "태영건설(주)",
|
||||
quantity: 10,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-003",
|
||||
productionOrderNumber: "PO-KD-TS-251217-06",
|
||||
orderNumber: "KD-TS-251217-06",
|
||||
siteName: "예술 검실 푸르지오",
|
||||
client: "롯데건설(주)",
|
||||
quantity: 1,
|
||||
dueDate: "2026-02-10",
|
||||
productionOrderDate: "2025-12-22",
|
||||
status: "waiting",
|
||||
workOrderCount: 0,
|
||||
},
|
||||
{
|
||||
id: "PO-004",
|
||||
productionOrderNumber: "PO-KD-BD-251220-35",
|
||||
orderNumber: "KD-BD-251220-35",
|
||||
siteName: "[코레타스프] 판교 물류센터 철거현장",
|
||||
client: "현대건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2026-02-03",
|
||||
productionOrderDate: "2025-12-20",
|
||||
status: "in_progress",
|
||||
workOrderCount: 2,
|
||||
},
|
||||
{
|
||||
id: "PO-005",
|
||||
productionOrderNumber: "PO-KD-BD-251219-34",
|
||||
orderNumber: "KD-BD-251219-34",
|
||||
siteName: "[코레타스프1] 김포 6차 필라테스장",
|
||||
client: "신성플랜(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2026-01-15",
|
||||
productionOrderDate: "2025-12-19",
|
||||
status: "in_progress",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-006",
|
||||
productionOrderNumber: "PO-KD-TS-250401-29",
|
||||
orderNumber: "KD-TS-250401-29",
|
||||
siteName: "포레나 전주",
|
||||
client: "한화건설(주)",
|
||||
quantity: 2,
|
||||
dueDate: "2025-05-16",
|
||||
productionOrderDate: "2025-04-01",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-007",
|
||||
productionOrderNumber: "PO-KD-BD-250331-28",
|
||||
orderNumber: "KD-BD-250331-28",
|
||||
siteName: "포레나 수원",
|
||||
client: "포레나건설(주)",
|
||||
quantity: 4,
|
||||
dueDate: "2025-05-15",
|
||||
productionOrderDate: "2025-03-31",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
{
|
||||
id: "PO-008",
|
||||
productionOrderNumber: "PO-KD-TS-250314-23",
|
||||
orderNumber: "KD-TS-250314-23",
|
||||
siteName: "자이 흑산파크",
|
||||
client: "GS건설(주)",
|
||||
quantity: 3,
|
||||
dueDate: "2025-04-28",
|
||||
productionOrderDate: "2025-03-14",
|
||||
status: "completed",
|
||||
workOrderCount: 3,
|
||||
},
|
||||
];
|
||||
|
||||
// 진행 단계 컴포넌트
|
||||
function ProgressSteps({ statusCode }: { statusCode?: string }) {
|
||||
const getSteps = () => {
|
||||
// 기본: 생산지시 목록에 있으면 수주확정, 생산지시는 이미 완료
|
||||
const steps = [
|
||||
{ label: "수주확정", completed: true, active: false },
|
||||
{ label: "생산지시", completed: true, active: false },
|
||||
{ label: "작업지시", completed: false, active: false },
|
||||
{ label: "생산", completed: false, active: false },
|
||||
{ label: "검사출하", completed: false, active: false },
|
||||
];
|
||||
|
||||
if (!statusCode) return steps;
|
||||
|
||||
// IN_PROGRESS = 생산대기 (작업지시 배정 진행 중)
|
||||
if (statusCode === "IN_PROGRESS") {
|
||||
steps[2].active = true;
|
||||
}
|
||||
// IN_PRODUCTION = 생산중
|
||||
if (statusCode === "IN_PRODUCTION") {
|
||||
steps[2].completed = true;
|
||||
steps[3].active = true;
|
||||
}
|
||||
// PRODUCED = 생산완료
|
||||
if (statusCode === "PRODUCED") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].active = true;
|
||||
}
|
||||
// SHIPPING = 출하중
|
||||
if (statusCode === "SHIPPING") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].active = true;
|
||||
}
|
||||
// SHIPPED = 출하완료
|
||||
if (statusCode === "SHIPPED") {
|
||||
steps[2].completed = true;
|
||||
steps[3].completed = true;
|
||||
steps[4].completed = true;
|
||||
}
|
||||
|
||||
return steps;
|
||||
};
|
||||
|
||||
const steps = getSteps();
|
||||
function ProgressSteps() {
|
||||
const steps = [
|
||||
{ label: "수주확정", active: true, completed: true },
|
||||
{ label: "생산지시", active: true, completed: false },
|
||||
{ label: "작업지시", active: false, completed: false },
|
||||
{ label: "생산", active: false, completed: false },
|
||||
{ label: "검사출하", active: false, completed: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 py-4">
|
||||
@@ -136,16 +214,16 @@ function ProgressSteps({ statusCode }: { statusCode?: string }) {
|
||||
}
|
||||
|
||||
// 상태 배지 헬퍼
|
||||
function getStatusBadge(status: ProductionStatus) {
|
||||
function getStatusBadge(status: ProductionOrderStatus) {
|
||||
const config: Record<
|
||||
ProductionStatus,
|
||||
ProductionOrderStatus,
|
||||
{ label: string; className: string }
|
||||
> = {
|
||||
waiting: {
|
||||
label: "생산대기",
|
||||
className: "bg-yellow-100 text-yellow-700 border-yellow-200",
|
||||
},
|
||||
in_production: {
|
||||
in_progress: {
|
||||
label: "생산중",
|
||||
className: "bg-green-100 text-green-700 border-green-200",
|
||||
},
|
||||
@@ -161,34 +239,79 @@ function getStatusBadge(status: ProductionStatus) {
|
||||
// 테이블 컬럼 정의
|
||||
const TABLE_COLUMNS: TableColumn[] = [
|
||||
{ key: "no", label: "번호", className: "w-[60px] text-center" },
|
||||
{ key: "orderNumber", label: "수주번호", className: "min-w-[150px]", copyable: true },
|
||||
{ key: "siteName", label: "현장명", className: "min-w-[180px]", copyable: true },
|
||||
{ key: "clientName", label: "거래처", className: "min-w-[120px]", copyable: true },
|
||||
{ key: "nodeCount", label: "개소", className: "w-[80px] text-center", copyable: true },
|
||||
{ key: "deliveryDate", label: "납기", className: "w-[110px]", copyable: true },
|
||||
{ key: "productionOrderedAt", label: "생산지시일", className: "w-[110px]", copyable: true },
|
||||
{ key: "productionOrderNumber", label: "생산지시번호", className: "min-w-[150px]" },
|
||||
{ key: "orderNumber", label: "수주번호", className: "min-w-[140px]" },
|
||||
{ key: "siteName", label: "현장명", className: "min-w-[180px]" },
|
||||
{ key: "client", label: "거래처", className: "min-w-[120px]" },
|
||||
{ key: "quantity", label: "수량", className: "w-[80px] text-center" },
|
||||
{ key: "dueDate", label: "납기", className: "w-[110px]" },
|
||||
{ key: "productionOrderDate", label: "생산지시일", className: "w-[110px]" },
|
||||
{ key: "status", label: "상태", className: "w-[100px]" },
|
||||
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center", copyable: true },
|
||||
{ key: "workOrderCount", label: "작업지시", className: "w-[80px] text-center" },
|
||||
{ key: "actions", label: "작업", className: "w-[100px] text-center" },
|
||||
];
|
||||
|
||||
export default function ProductionOrdersListPage() {
|
||||
const router = useRouter();
|
||||
const [stats, setStats] = useState<ProductionOrderStats>({
|
||||
total: 0,
|
||||
waiting: 0,
|
||||
in_production: 0,
|
||||
completed: 0,
|
||||
const [orders, setOrders] = useState<ProductionOrder[]>(SAMPLE_PRODUCTION_ORDERS);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("all");
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 삭제 확인 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null); // 개별 삭제 시 사용
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredData = orders.filter((item) => {
|
||||
// 탭 필터
|
||||
if (activeTab !== "all") {
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
if (item.status !== statusMap[activeTab]) return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 통계 로드
|
||||
useEffect(() => {
|
||||
getProductionOrderStats().then((result) => {
|
||||
if (result.success && result.data) {
|
||||
setStats(result.data);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(filteredData.length / itemsPerPage);
|
||||
const paginatedData = filteredData.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
// 탭별 건수
|
||||
const tabCounts = {
|
||||
all: orders.length,
|
||||
waiting: orders.filter((i) => i.status === "waiting").length,
|
||||
in_progress: orders.filter((i) => i.status === "in_progress").length,
|
||||
completed: orders.filter((i) => i.status === "completed").length,
|
||||
};
|
||||
|
||||
// 탭 옵션
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: tabCounts.all },
|
||||
{ value: "waiting", label: "생산대기", count: tabCounts.waiting, color: "yellow" },
|
||||
{ value: "in_progress", label: "생산중", count: tabCounts.in_progress, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: tabCounts.completed, color: "gray" },
|
||||
];
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/sales/order-management-sales");
|
||||
@@ -202,13 +325,57 @@ export default function ProductionOrdersListPage() {
|
||||
router.push(`/sales/order-management-sales/production-orders/${item.id}?mode=view`);
|
||||
};
|
||||
|
||||
// 탭 옵션 (통계 기반 동적 카운트)
|
||||
const tabs: TabOption[] = [
|
||||
{ value: "all", label: "전체", count: stats.total },
|
||||
{ value: "waiting", label: "생산대기", count: stats.waiting, color: "yellow" },
|
||||
{ value: "in_production", label: "생산중", count: stats.in_production, color: "green" },
|
||||
{ value: "completed", label: "생산완료", count: stats.completed, color: "gray" },
|
||||
];
|
||||
// 개별 삭제 다이얼로그 열기
|
||||
const handleDelete = (item: ProductionOrder) => {
|
||||
setDeleteTargetId(item.id);
|
||||
setShowDeleteDialog(true);
|
||||
};
|
||||
|
||||
// 체크박스 선택
|
||||
const toggleSelection = (id: string) => {
|
||||
const newSelection = new Set(selectedItems);
|
||||
if (newSelection.has(id)) {
|
||||
newSelection.delete(id);
|
||||
} else {
|
||||
newSelection.add(id);
|
||||
}
|
||||
setSelectedItems(newSelection);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((item) => item.id)));
|
||||
}
|
||||
};
|
||||
|
||||
// 일괄 삭제 다이얼로그 열기
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedItems.size > 0) {
|
||||
setDeleteTargetId(null); // 일괄 삭제
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 개수 계산 (개별 삭제 시 1, 일괄 삭제 시 selectedItems.size)
|
||||
const deleteCount = deleteTargetId ? 1 : selectedItems.size;
|
||||
|
||||
// 실제 삭제 실행
|
||||
const handleConfirmDelete = () => {
|
||||
if (deleteTargetId) {
|
||||
// 개별 삭제
|
||||
setOrders(orders.filter((o) => o.id !== deleteTargetId));
|
||||
setSelectedItems(new Set([...selectedItems].filter(id => id !== deleteTargetId)));
|
||||
} else {
|
||||
// 일괄 삭제
|
||||
const selectedIds = Array.from(selectedItems);
|
||||
setOrders(orders.filter((o) => !selectedIds.includes(o.id)));
|
||||
setSelectedItems(new Set());
|
||||
}
|
||||
setShowDeleteDialog(false);
|
||||
setDeleteTargetId(null);
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = (
|
||||
@@ -235,17 +402,22 @@ export default function ProductionOrdersListPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded">
|
||||
{item.productionOrderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">
|
||||
{item.orderNumber}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{item.siteName}
|
||||
</TableCell>
|
||||
<TableCell>{item.clientName}</TableCell>
|
||||
<TableCell className="text-center">{formatNumber(item.nodeCount)}개소</TableCell>
|
||||
<TableCell>{item.deliveryDate}</TableCell>
|
||||
<TableCell>{item.productionOrderedAt}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.productionStatus)}</TableCell>
|
||||
<TableCell>{item.client}</TableCell>
|
||||
<TableCell className="text-center">{item.quantity}개</TableCell>
|
||||
<TableCell>{item.dueDate}</TableCell>
|
||||
<TableCell>{item.productionOrderDate}</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{item.workOrderCount > 0 ? (
|
||||
<Badge variant="outline">{item.workOrderCount}건</Badge>
|
||||
@@ -259,6 +431,9 @@ export default function ProductionOrdersListPage() {
|
||||
<Button variant="ghost" size="sm" onClick={() => handleView(item)}>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => handleDelete(item)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
@@ -288,19 +463,19 @@ export default function ProductionOrdersListPage() {
|
||||
variant="outline"
|
||||
className="bg-blue-50 text-blue-700 font-mono text-xs"
|
||||
>
|
||||
{item.orderNumber}
|
||||
{item.productionOrderNumber}
|
||||
</Badge>
|
||||
{getStatusBadge(item.productionStatus)}
|
||||
{getStatusBadge(item.status)}
|
||||
</>
|
||||
}
|
||||
infoGrid={
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||
<InfoField label="수주번호" value={item.orderNumber} />
|
||||
<InfoField label="현장명" value={item.siteName} />
|
||||
<InfoField label="거래처" value={item.clientName} />
|
||||
<InfoField label="개소" value={`${formatNumber(item.nodeCount)}개소`} />
|
||||
<InfoField label="납기" value={item.deliveryDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderedAt} />
|
||||
<InfoField label="거래처" value={item.client} />
|
||||
<InfoField label="수량" value={`${item.quantity}개`} />
|
||||
<InfoField label="납기" value={item.dueDate} />
|
||||
<InfoField label="생산지시일" value={item.productionOrderDate} />
|
||||
<InfoField
|
||||
label="작업지시"
|
||||
value={item.workOrderCount > 0 ? `${item.workOrderCount}건` : "-"}
|
||||
@@ -322,6 +497,18 @@ export default function ProductionOrdersListPage() {
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
상세
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="flex-1 min-w-[100px] h-11"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -329,43 +516,6 @@ export default function ProductionOrdersListPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// getList API 호출
|
||||
const getList = useCallback(async (params?: { page?: number; pageSize?: number; search?: string; tab?: string }) => {
|
||||
const productionStatus = params?.tab && params.tab !== "all"
|
||||
? (params.tab as ProductionStatus)
|
||||
: undefined;
|
||||
|
||||
const result = await getProductionOrders({
|
||||
search: params?.search,
|
||||
productionStatus,
|
||||
page: params?.page,
|
||||
perPage: params?.pageSize,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
// 통계 새로고침
|
||||
getProductionOrderStats().then((statsResult) => {
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: result.data,
|
||||
totalCount: result.pagination?.total || 0,
|
||||
totalPages: result.pagination?.lastPage || 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
error: result.error,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ===== UniversalListPage 설정 =====
|
||||
const productionOrderConfig: UniversalListConfig<ProductionOrder> = {
|
||||
title: "생산지시 목록",
|
||||
@@ -375,19 +525,43 @@ export default function ProductionOrdersListPage() {
|
||||
idField: "id",
|
||||
|
||||
actions: {
|
||||
getList,
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data: orders,
|
||||
totalCount: orders.length,
|
||||
}),
|
||||
},
|
||||
|
||||
columns: TABLE_COLUMNS,
|
||||
|
||||
tabs: tabs,
|
||||
defaultTab: "all",
|
||||
defaultTab: activeTab,
|
||||
|
||||
searchPlaceholder: "수주번호, 현장명, 거래처 검색...",
|
||||
searchPlaceholder: "생산지시번호, 수주번호, 현장명 검색...",
|
||||
|
||||
itemsPerPage: 20,
|
||||
itemsPerPage,
|
||||
|
||||
clientSideFiltering: false,
|
||||
clientSideFiltering: true,
|
||||
|
||||
searchFilter: (item, searchValue) => {
|
||||
const term = searchValue.toLowerCase();
|
||||
return (
|
||||
item.productionOrderNumber.toLowerCase().includes(term) ||
|
||||
item.orderNumber.toLowerCase().includes(term) ||
|
||||
item.siteName.toLowerCase().includes(term) ||
|
||||
item.client.toLowerCase().includes(term)
|
||||
);
|
||||
},
|
||||
|
||||
tabFilter: (item, tabValue) => {
|
||||
if (tabValue === "all") return true;
|
||||
const statusMap: Record<string, ProductionOrderStatus> = {
|
||||
waiting: "waiting",
|
||||
in_progress: "in_progress",
|
||||
completed: "completed",
|
||||
};
|
||||
return item.status === statusMap[tabValue];
|
||||
},
|
||||
|
||||
headerActions: () => (
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
@@ -406,11 +580,50 @@ export default function ProductionOrdersListPage() {
|
||||
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
|
||||
renderDialogs: () => (
|
||||
<DeleteConfirmDialog
|
||||
open={showDeleteDialog}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="삭제 확인"
|
||||
description={
|
||||
<>
|
||||
선택한 <strong>{deleteCount}개</strong>의 항목을 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
삭제된 항목은 복구할 수 없습니다. 관련된 데이터도 함께 삭제될 수 있습니다.
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<UniversalListPage<ProductionOrder>
|
||||
config={productionOrderConfig}
|
||||
initialData={orders}
|
||||
initialTotalCount={orders.length}
|
||||
externalSelection={{
|
||||
selectedItems,
|
||||
onToggleSelection: toggleSelection,
|
||||
onToggleSelectAll: toggleSelectAll,
|
||||
setSelectedItems,
|
||||
getItemId: (item: ProductionOrder) => item.id,
|
||||
}}
|
||||
onTabChange={(value: string) => {
|
||||
setActiveTab(value);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
onSearchChange={setSearchTerm}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: filteredData.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,7 +89,6 @@ export default function QuoteDetailPage() {
|
||||
|
||||
v2Data.locations = updatedLocations;
|
||||
} else {
|
||||
// no BOM result to merge
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { transformApiMenusToMenuItems } from '@/lib/utils/menuTransform';
|
||||
import { performFullLogout } from '@/lib/auth/logout';
|
||||
|
||||
/**
|
||||
* MNG 관리자 패널 → SAM 자동 로그인 페이지
|
||||
*
|
||||
* 흐름:
|
||||
* 1. MNG에서 "SAM 접속" 클릭 → /auto-login?token=xxx 로 새 창 열림
|
||||
* 2. 기존 세션 로그아웃 (쿠키 + localStorage + Zustand 초기화)
|
||||
* 3. One-Time Token으로 API 호출 → 새 세션 생성
|
||||
* 4. 사용자 정보 저장 후 /dashboard로 이동
|
||||
*/
|
||||
export default function AutoLoginPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [status, setStatus] = useState<'processing' | 'error'>('processing');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
setStatus('error');
|
||||
setErrorMessage('로그인 토큰이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const performAutoLogin = async () => {
|
||||
try {
|
||||
// 1. 기존 세션 완전 로그아웃 (쿠키 삭제 + 스토어 초기화)
|
||||
await performFullLogout({ skipServerLogout: false });
|
||||
|
||||
// 2. One-Time Token으로 로그인
|
||||
const response = await fetch('/api/auth/token-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '자동 로그인에 실패했습니다.');
|
||||
}
|
||||
|
||||
// 3. 사용자 정보 localStorage 저장 (LoginPage와 동일 패턴)
|
||||
const transformedMenus = transformApiMenusToMenuItems(data.menus || []);
|
||||
|
||||
const userData = {
|
||||
id: data.user?.id,
|
||||
name: data.user?.name,
|
||||
position: data.roles?.[0]?.description || '사용자',
|
||||
userId: data.user?.user_id,
|
||||
department: data.user?.department || null,
|
||||
department_id: data.user?.department_id || null,
|
||||
menu: transformedMenus,
|
||||
roles: data.roles || [],
|
||||
tenant: data.tenant || {},
|
||||
};
|
||||
localStorage.setItem('user', JSON.stringify(userData));
|
||||
|
||||
// 4. persist store rehydrate
|
||||
const { useFavoritesStore } = await import('@/stores/favoritesStore');
|
||||
const { useTableColumnStore } = await import('@/stores/useTableColumnStore');
|
||||
useFavoritesStore.persist.rehydrate();
|
||||
useTableColumnStore.persist.rehydrate();
|
||||
|
||||
// 5. 로그인 플래그 설정
|
||||
sessionStorage.setItem('auth_just_logged_in', 'true');
|
||||
|
||||
// 6. 대시보드로 이동
|
||||
router.push('/dashboard');
|
||||
} catch (err) {
|
||||
console.error('자동 로그인 실패:', err);
|
||||
setStatus('error');
|
||||
setErrorMessage(err instanceof Error ? err.message : '자동 로그인에 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
performAutoLogin();
|
||||
}, [searchParams, router]);
|
||||
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-4 p-8">
|
||||
<div className="text-destructive text-lg font-semibold">자동 로그인 실패</div>
|
||||
<p className="text-muted-foreground">{errorMessage}</p>
|
||||
<button
|
||||
onClick={() => router.push('/login')}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition"
|
||||
>
|
||||
로그인 페이지로 이동
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="inline-block h-10 w-10 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent" />
|
||||
<p className="text-muted-foreground">자동 로그인 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* 🔵 Next.js 내부 API - 토큰 자동 로그인 프록시
|
||||
*
|
||||
* MNG 관리자 패널에서 "SAM 접속" 버튼 클릭 시 사용
|
||||
* One-Time Token으로 사용자 인증 후 HttpOnly 쿠키 설정
|
||||
*
|
||||
* 🔄 동작 흐름:
|
||||
* 1. 클라이언트 → Next.js /api/auth/token-login (token)
|
||||
* 2. Next.js → PHP /api/v1/token-login (토큰 검증)
|
||||
* 3. PHP → Next.js (access_token, refresh_token, 사용자 정보)
|
||||
* 4. Next.js: 토큰을 HttpOnly 쿠키로 설정
|
||||
* 5. Next.js → 클라이언트 (토큰 제외한 사용자 정보만 전달)
|
||||
*/
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { token } = body;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: '토큰이 필요합니다.' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// PHP 백엔드 API 호출
|
||||
const backendResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/token-login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
'X-API-KEY': process.env.API_KEY || '',
|
||||
},
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!backendResponse.ok) {
|
||||
const errorData = await backendResponse.json().catch(() => ({}));
|
||||
return NextResponse.json(
|
||||
{ error: errorData.error || '토큰 인증에 실패했습니다.' },
|
||||
{ status: backendResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await backendResponse.json();
|
||||
|
||||
// 클라이언트에 전달할 응답 (토큰 제외)
|
||||
const responseData = {
|
||||
message: data.message,
|
||||
user: data.user,
|
||||
tenant: data.tenant,
|
||||
menus: data.menus,
|
||||
roles: data.roles,
|
||||
token_type: data.token_type,
|
||||
expires_in: data.expires_in,
|
||||
expires_at: data.expires_at,
|
||||
};
|
||||
|
||||
// HttpOnly 쿠키 설정 (login/route.ts와 동일한 패턴)
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
const accessTokenCookie = [
|
||||
`access_token=${data.access_token}`,
|
||||
'HttpOnly',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
`Max-Age=${data.expires_in || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
const refreshTokenCookie = [
|
||||
`refresh_token=${data.refresh_token}`,
|
||||
'HttpOnly',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
'Max-Age=604800',
|
||||
].join('; ');
|
||||
|
||||
const isAuthenticatedCookie = [
|
||||
'is_authenticated=true',
|
||||
...(isProduction ? ['Secure'] : []),
|
||||
'SameSite=Lax',
|
||||
'Path=/',
|
||||
`Max-Age=${data.expires_in || 7200}`,
|
||||
].join('; ');
|
||||
|
||||
const response = NextResponse.json(responseData, { status: 200 });
|
||||
|
||||
response.headers.append('Set-Cookie', accessTokenCookie);
|
||||
response.headers.append('Set-Cookie', refreshTokenCookie);
|
||||
response.headers.append('Set-Cookie', isAuthenticatedCookie);
|
||||
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Token login proxy error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { authenticatedFetch } from '@/lib/api/authenticated-fetch';
|
||||
import { stripJsonTrailingData } from '@/lib/api/safe-json-parse';
|
||||
|
||||
/**
|
||||
* 🔵 Catch-All API Proxy (HttpOnly Cookie Pattern)
|
||||
@@ -191,35 +190,14 @@ async function proxyRequest(
|
||||
},
|
||||
});
|
||||
} else {
|
||||
let responseData = await backendResponse.text();
|
||||
const responseData = await backendResponse.text();
|
||||
|
||||
// 백엔드가 HTML 에러 페이지를 반환한 경우 (404/500 등)
|
||||
// HTML을 그대로 전달하면 클라이언트 response.json()에서 SyntaxError 발생
|
||||
// → 안전한 JSON 에러 응답으로 변환
|
||||
if (!backendResponse.ok && !responseData.trimStart().startsWith('{') && !responseData.trimStart().startsWith('[')) {
|
||||
const status = backendResponse.status;
|
||||
clientResponse = NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
message: status === 404
|
||||
? '요청한 API를 찾을 수 없습니다.'
|
||||
: `서버 오류가 발생했습니다. (${status})`,
|
||||
error: { code: status },
|
||||
},
|
||||
{ status }
|
||||
);
|
||||
} else {
|
||||
// PHP trailing output 제거 (JSON 뒤에 warning/error 텍스트가 붙는 경우)
|
||||
if (responseContentType.includes('application/json') || responseData.trimStart().startsWith('{') || responseData.trimStart().startsWith('[')) {
|
||||
responseData = stripJsonTrailingData(responseData);
|
||||
}
|
||||
clientResponse = new NextResponse(responseData, {
|
||||
status: backendResponse.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
clientResponse = new NextResponse(responseData, {
|
||||
status: backendResponse.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 8. 토큰이 갱신되었으면 새 쿠키 설정
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
@@ -138,14 +137,12 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
if (isNewMode) {
|
||||
const result = await createBadDebt(formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '등록에 실패했습니다.' };
|
||||
} else {
|
||||
const result = await updateBadDebt(recordId!, formData);
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '수정에 실패했습니다.' };
|
||||
@@ -162,7 +159,6 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
try {
|
||||
const result = await deleteBadDebt(String(id));
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error || '삭제에 실패했습니다.' };
|
||||
@@ -271,7 +267,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
}, [router, formData.vendorId]);
|
||||
|
||||
// 파일 다운로드 핸들러
|
||||
const handleFileDownload = useCallback((_fileName: string) => {
|
||||
const handleFileDownload = useCallback((fileName: string) => {
|
||||
// TODO: 실제 다운로드 로직
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { BadDebtDetail } from './BadDebtDetail';
|
||||
import { getBadDebtById } from './actions';
|
||||
import type { BadDebtRecord } from './types';
|
||||
@@ -26,6 +26,7 @@ interface BadDebtDetailClientV2Props {
|
||||
const BASE_PATH = '/ko/accounting/bad-debt-collection';
|
||||
|
||||
export function BadDebtDetailClientV2({ recordId, initialMode }: BadDebtDetailClientV2Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// URL 쿼리에서 모드 결정
|
||||
|
||||
@@ -18,7 +18,7 @@ import { revalidatePath } from 'next/cache';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { PaginatedApiResponse } from '@/lib/api/types';
|
||||
import type { BadDebtRecord, CollectionStatus } from './types';
|
||||
import type { BadDebtRecord, BadDebtItem, CollectionStatus } from './types';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ export { BadDebtDetailClientV2 } from './BadDebtDetailClientV2';
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useTransition } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -51,10 +50,10 @@ import { deleteBadDebt, toggleBadDebt } from './actions';
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
|
||||
{ key: 'vendorName', label: '거래처', className: 'w-[100px]', sortable: true, copyable: true },
|
||||
{ key: 'debtAmount', label: '채권금액', className: 'text-right w-[140px]', sortable: true, copyable: true },
|
||||
{ key: 'occurrenceDate', label: '발생일', className: 'text-center w-[110px]', sortable: true, copyable: true },
|
||||
{ key: 'overdueDays', label: '연체일수', className: 'text-center w-[100px]', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처', className: 'w-[100px]', sortable: true },
|
||||
{ key: 'debtAmount', label: '채권금액', className: 'text-right w-[140px]', sortable: true },
|
||||
{ key: 'occurrenceDate', label: '발생일', className: 'text-center w-[110px]', sortable: true },
|
||||
{ key: 'overdueDays', label: '연체일수', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'managerName', label: '담당자', className: 'w-[100px]', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'setting', label: '설정', className: 'text-center w-[80px]' },
|
||||
@@ -177,7 +176,6 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBadDebt(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('badDebt');
|
||||
setData((prev) => prev.filter((item) => item.id !== id));
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
|
||||
@@ -76,15 +76,15 @@ const excelColumns: ExcelColumn<BankTransaction & Record<string, unknown>>[] = [
|
||||
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
|
||||
const tableColumns = [
|
||||
{ key: 'rowNumber', label: 'No.', className: 'text-center w-[50px]' },
|
||||
{ key: 'transactionDate', label: '거래일시', sortable: true, copyable: true },
|
||||
{ key: 'type', label: '구분', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'accountInfo', label: '계좌정보', sortable: true, copyable: true },
|
||||
{ key: 'note', label: '적요/내용', sortable: true, copyable: true },
|
||||
{ key: 'depositAmount', label: '입금', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'withdrawalAmount', label: '출금', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'balance', label: '잔액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'branch', label: '취급점', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'depositorName', label: '상대계좌예금주명', sortable: true, copyable: true },
|
||||
{ key: 'transactionDate', label: '거래일시', sortable: true },
|
||||
{ key: 'type', label: '구분', className: 'text-center', sortable: true },
|
||||
{ key: 'accountInfo', label: '계좌정보', sortable: true },
|
||||
{ key: 'note', label: '적요/내용', sortable: true },
|
||||
{ key: 'depositAmount', label: '입금', className: 'text-right', sortable: true },
|
||||
{ key: 'withdrawalAmount', label: '출금', className: 'text-right', sortable: true },
|
||||
{ key: 'balance', label: '잔액', className: 'text-right', sortable: true },
|
||||
{ key: 'branch', label: '취급점', className: 'text-center', sortable: true },
|
||||
{ key: 'depositorName', label: '상대계좌예금주명', sortable: true },
|
||||
];
|
||||
|
||||
// ===== 기본 Summary =====
|
||||
@@ -112,7 +112,7 @@ export function BankTransactionInquiry() {
|
||||
|
||||
// 필터 상태
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption] = useState<SortOption>('latest');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [accountCategoryFilter, setAccountCategoryFilter] = useState<AccountCategoryFilter>('all');
|
||||
const [financialInstitutionFilter, setFinancialInstitutionFilter] = useState('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
|
||||
@@ -9,7 +9,6 @@ import { apiDataToFormData, transformFormDataToApi } from './types';
|
||||
import type { BillApiData } from './types';
|
||||
import { getBillRaw, createBillRaw, updateBillRaw, deleteBill, getClients } from './actions';
|
||||
import { useBillForm } from './hooks/useBillForm';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useBillConditions } from './hooks/useBillConditions';
|
||||
import {
|
||||
BasicInfoSection,
|
||||
@@ -131,7 +130,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
if (isNewMode) {
|
||||
const result = await createBillRaw(apiPayload);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success('등록되었습니다.');
|
||||
router.push('/ko/accounting/bills');
|
||||
return { success: false, error: '' };
|
||||
@@ -139,9 +137,6 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
return result;
|
||||
} else {
|
||||
const result = await updateBillRaw(String(billId), apiPayload);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -17,12 +17,13 @@ import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
FileText,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
@@ -42,6 +43,7 @@ import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
BillRecord,
|
||||
BillType,
|
||||
BillStatus,
|
||||
SortOption,
|
||||
} from './types';
|
||||
@@ -50,8 +52,6 @@ import {
|
||||
BILL_TYPE_FILTER_OPTIONS,
|
||||
BILL_STATUS_COLORS,
|
||||
BILL_STATUS_FILTER_OPTIONS,
|
||||
RECEIVED_BILL_STATUS_OPTIONS,
|
||||
ISSUED_BILL_STATUS_OPTIONS,
|
||||
getBillStatusLabel,
|
||||
} from './types';
|
||||
import { getBills, deleteBill, updateBillStatus } from './actions';
|
||||
@@ -81,15 +81,32 @@ export function BillManagementClient({
|
||||
const [pagination, setPagination] = useState(initialPagination);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption] = useState<SortOption>('latest');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [billTypeFilter, setBillTypeFilter] = useState<string>(initialBillType || 'received');
|
||||
const [vendorFilter, setVendorFilter] = useState<string>(initialVendorId || 'all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [targetStatus, setTargetStatus] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
|
||||
const itemsPerPage = initialPagination.perPage;
|
||||
|
||||
// 삭제 다이얼로그
|
||||
const deleteDialog = useDeleteDialog({
|
||||
onDelete: async (id) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
|
||||
await loadData(currentPage);
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
},
|
||||
entityName: '어음',
|
||||
});
|
||||
|
||||
// 날짜 범위 상태
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
|
||||
|
||||
@@ -172,13 +189,13 @@ export function BillManagementClient({
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'billNumber', label: '어음번호', sortable: true, copyable: true },
|
||||
{ key: 'billNumber', label: '어음번호', sortable: true },
|
||||
{ key: 'billType', label: '구분', className: 'text-center', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
|
||||
{ key: 'amount', label: '금액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'issueDate', label: '발행일', sortable: true, copyable: true },
|
||||
{ key: 'maturityDate', label: '만기일', sortable: true, copyable: true },
|
||||
{ key: 'installmentCount', label: '차수', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true },
|
||||
{ key: 'amount', label: '금액', className: 'text-right', sortable: true },
|
||||
{ key: 'issueDate', label: '발행일', sortable: true },
|
||||
{ key: 'maturityDate', label: '만기일', sortable: true },
|
||||
{ key: 'installmentCount', label: '차수', className: 'text-center', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
|
||||
], []);
|
||||
|
||||
@@ -264,15 +281,15 @@ export function BillManagementClient({
|
||||
];
|
||||
}, [data]);
|
||||
|
||||
// ===== 상태 변경 핸들러 =====
|
||||
const handleStatusChange = useCallback(async () => {
|
||||
// ===== 저장 핸들러 =====
|
||||
const handleSave = useCallback(async () => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('선택된 항목이 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!targetStatus) {
|
||||
toast.warning('변경할 상태를 선택해주세요.');
|
||||
if (statusFilter === 'all') {
|
||||
toast.warning('상태를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -280,28 +297,21 @@ export function BillManagementClient({
|
||||
let successCount = 0;
|
||||
|
||||
for (const id of selectedItems) {
|
||||
const result = await updateBillStatus(id, targetStatus as BillStatus);
|
||||
const result = await updateBillStatus(id, statusFilter as BillStatus);
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
|
||||
toast.success(`${successCount}건이 저장되었습니다.`);
|
||||
loadData(currentPage);
|
||||
setSelectedItems(new Set());
|
||||
setTargetStatus('');
|
||||
} else {
|
||||
toast.error('상태 변경에 실패했습니다.');
|
||||
toast.error('저장에 실패했습니다.');
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [selectedItems, targetStatus, loadData, currentPage]);
|
||||
|
||||
// 구분에 따른 상태 옵션
|
||||
const statusChangeOptions = useMemo(() => {
|
||||
return billTypeFilter === 'issued' ? ISSUED_BILL_STATUS_OPTIONS : RECEIVED_BILL_STATUS_OPTIONS;
|
||||
}, [billTypeFilter]);
|
||||
}, [selectedItems, statusFilter, loadData, currentPage]);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<BillRecord> = useMemo(
|
||||
@@ -324,25 +334,6 @@ export function BillManagementClient({
|
||||
totalCount: pagination.total,
|
||||
};
|
||||
},
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
await loadData(currentPage);
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
// 삭제 확인 메시지
|
||||
deleteConfirmMessage: {
|
||||
title: '어음 삭제',
|
||||
description: '이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.',
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
@@ -385,30 +376,12 @@ export function BillManagementClient({
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 선택 시 상태 변경 액션
|
||||
selectionActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={targetStatus} onValueChange={setTargetStatus}>
|
||||
<SelectTrigger className="min-w-[130px] w-auto h-8">
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{statusChangeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleStatusChange}
|
||||
disabled={!targetStatus || isLoading}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
상태변경
|
||||
</Button>
|
||||
</div>
|
||||
// 헤더 액션: 저장 버튼만 (필터는 tableHeaderActions에서 통합 관리)
|
||||
headerActions: () => (
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (필터)
|
||||
@@ -472,10 +445,7 @@ export function BillManagementClient({
|
||||
isLoading,
|
||||
router,
|
||||
loadData,
|
||||
currentPage,
|
||||
handleStatusChange,
|
||||
statusChangeOptions,
|
||||
targetStatus,
|
||||
handleSave,
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
]
|
||||
@@ -501,6 +471,14 @@ export function BillManagementClient({
|
||||
}}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialog.single.isOpen}
|
||||
onOpenChange={deleteDialog.single.onOpenChange}
|
||||
onConfirm={deleteDialog.single.confirm}
|
||||
title="어음 삭제"
|
||||
description="이 어음을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다."
|
||||
loading={deleteDialog.isPending}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -158,7 +158,7 @@ export async function updateBillRaw(id: string, data: Record<string, unknown>):
|
||||
// ===== 거래처 목록 조회 =====
|
||||
export async function getClients(): Promise<ActionResult<{ id: number; name: string }[]>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
|
||||
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
|
||||
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
|
||||
type ClientApi = { id: number; name: string };
|
||||
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
|
||||
|
||||
@@ -16,7 +16,6 @@ import { useRouter } from 'next/navigation';
|
||||
import { toast } from 'sonner';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { getBills, deleteBill, updateBillStatus } from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import { extractUniqueOptions } from '../shared';
|
||||
import {
|
||||
@@ -62,13 +61,13 @@ import {
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'billNumber', label: '어음번호', sortable: true, copyable: true },
|
||||
{ key: 'billNumber', label: '어음번호', sortable: true },
|
||||
{ key: 'billType', label: '구분', className: 'text-center', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
|
||||
{ key: 'amount', label: '금액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'issueDate', label: '발행일', sortable: true, copyable: true },
|
||||
{ key: 'maturityDate', label: '만기일', sortable: true, copyable: true },
|
||||
{ key: 'installmentCount', label: '차수', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true },
|
||||
{ key: 'amount', label: '금액', className: 'text-right', sortable: true },
|
||||
{ key: 'issueDate', label: '발행일', sortable: true },
|
||||
{ key: 'maturityDate', label: '만기일', sortable: true },
|
||||
{ key: 'installmentCount', label: '차수', className: 'text-center', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
];
|
||||
@@ -94,7 +93,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
const [billTypeFilter, setBillTypeFilter] = useState<string>(initialBillType || 'received');
|
||||
const [vendorFilter, setVendorFilter] = useState<string>(initialVendorId || 'all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortOption, _setSortOption] = useState<SortOption>('latest');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
|
||||
// 페이지네이션
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
@@ -210,7 +209,6 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
invalidateDashboard('bill');
|
||||
toast.success(`${successCount}건의 상태가 변경되었습니다.`);
|
||||
await loadBills();
|
||||
}
|
||||
@@ -249,7 +247,6 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteBill(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('bill');
|
||||
// 서버에서 재조회 (pagination 메타데이터 포함)
|
||||
await loadBills();
|
||||
}
|
||||
|
||||
@@ -23,8 +23,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { CardTransaction, JournalEntryItem } from './types';
|
||||
import { DEDUCTION_OPTIONS } from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
|
||||
import { saveJournalEntries } from './actions';
|
||||
|
||||
interface JournalEntryModalProps {
|
||||
@@ -195,16 +194,23 @@ export function JournalEntryModal({ open, onOpenChange, transaction, onSuccess }
|
||||
|
||||
{/* 계정과목 + 공제 + 증빙/판매자상호 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{/* Select - FormField 예외 */}
|
||||
<div>
|
||||
<Label className="text-xs">계정과목</Label>
|
||||
<div className="mt-1">
|
||||
<AccountSubjectSelect
|
||||
value={item.accountSubject}
|
||||
onValueChange={(v) => updateItem(index, 'accountSubject', v)}
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={item.accountSubject || 'none'}
|
||||
onValueChange={(v) => updateItem(index, 'accountSubject', v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Select - FormField 예외 */}
|
||||
<div>
|
||||
|
||||
@@ -25,8 +25,7 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import type { ManualInputFormData } from './types';
|
||||
import { DEDUCTION_OPTIONS } from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS } from './types';
|
||||
import { getCardList, createCardTransaction } from './actions';
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
|
||||
@@ -255,13 +254,20 @@ export function ManualInputModal({ open, onOpenChange, onSuccess }: ManualInputM
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm font-medium">계정과목</Label>
|
||||
<div className="mt-1">
|
||||
<AccountSubjectSelect
|
||||
value={formData.accountSubject}
|
||||
onValueChange={(v) => handleChange('accountSubject', v)}
|
||||
placeholder="선택"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={formData.accountSubject || 'none'}
|
||||
onValueChange={(v) => handleChange('accountSubject', v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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,7 +42,6 @@ import type { CardTransaction, InlineEditData, SortOption } from './types';
|
||||
import {
|
||||
SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import {
|
||||
getCardTransactionList,
|
||||
getCardTransactionSummary,
|
||||
@@ -83,19 +82,19 @@ const excelColumns: ExcelColumn<CardTransaction>[] = [
|
||||
// ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) =====
|
||||
const tableColumns = [
|
||||
{ key: 'rowNumber', label: 'No.', className: 'text-center w-[50px]' },
|
||||
{ key: 'usedAt', label: '사용일시', className: 'min-w-[130px]', copyable: true },
|
||||
{ key: 'cardCompany', label: '카드사', className: 'min-w-[80px]', copyable: true },
|
||||
{ key: 'card', label: '카드번호', className: 'min-w-[100px]', copyable: true },
|
||||
{ key: 'cardName', label: '카드명', className: 'min-w-[80px]', copyable: true },
|
||||
{ key: 'usedAt', label: '사용일시', className: 'min-w-[130px]' },
|
||||
{ key: 'cardCompany', label: '카드사', className: 'min-w-[80px]' },
|
||||
{ key: 'card', label: '카드번호', className: 'min-w-[100px]' },
|
||||
{ key: 'cardName', label: '카드명', className: 'min-w-[80px]' },
|
||||
{ key: 'deductionType', label: '공제', className: 'min-w-[95px]', sortable: false },
|
||||
{ key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]', copyable: true },
|
||||
{ key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]', copyable: true },
|
||||
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[160px]', sortable: false, copyable: true },
|
||||
{ key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false, copyable: true },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right', copyable: true },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false, copyable: true },
|
||||
{ key: 'taxAmount', label: '세액', className: 'min-w-[90px] text-right', sortable: false, copyable: true },
|
||||
{ key: 'accountSubject', label: '계정과목', className: 'min-w-[100px]', sortable: false, copyable: true },
|
||||
{ key: 'businessNumber', label: '사업자번호', className: 'min-w-[110px]' },
|
||||
{ key: 'merchantName', label: '가맹점명', className: 'min-w-[100px]' },
|
||||
{ key: 'vendorName', label: '증빙/판매자상호', className: 'min-w-[130px]', sortable: false },
|
||||
{ key: 'description', label: '내역', className: 'min-w-[120px]', sortable: false },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'min-w-[100px] text-right' },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'min-w-[110px] text-right', sortable: false },
|
||||
{ key: 'taxAmount', label: '세액', className: 'min-w-[90px] text-right', sortable: false },
|
||||
{ key: 'accountSubject', label: '계정과목', className: 'min-w-[100px]', sortable: false },
|
||||
{ key: 'journalEntry', label: '분개', className: 'w-16 text-center', sortable: false },
|
||||
{ key: 'hide', label: '숨김', className: 'w-16 text-center', sortable: false },
|
||||
];
|
||||
@@ -600,13 +599,20 @@ export function CardTransactionInquiry() {
|
||||
</TableCell>
|
||||
{/* 계정과목 (인라인 Select) */}
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<AccountSubjectSelect
|
||||
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || ''}
|
||||
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v)}
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
className="min-w-[90px] w-auto"
|
||||
/>
|
||||
<Select
|
||||
value={getEditValue(item.id, 'accountSubject', item.accountSubject) || 'none'}
|
||||
onValueChange={(v) => handleInlineEdit(item.id, 'accountSubject', v === 'none' ? '' : v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs min-w-[90px] w-auto">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(o => o.value !== '').map(opt => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
{/* 분개 버튼 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use server';
|
||||
|
||||
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { NoteReceivableItem, DailyAccountItem, MatchStatus } from './types';
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
getBankAccounts,
|
||||
} from './actions';
|
||||
import { useDevFill, generateDepositData } from '@/components/dev';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
|
||||
// ===== Props =====
|
||||
interface DepositDetailClientV2Props {
|
||||
@@ -82,17 +81,14 @@ export default function DepositDetailClientV2({
|
||||
: await updateDeposit(depositId!, submitData as Partial<DepositRecord>);
|
||||
|
||||
if (result.success && mode === 'create') {
|
||||
invalidateDashboard('deposit');
|
||||
toast.success('등록되었습니다.');
|
||||
router.push('/ko/accounting/deposits');
|
||||
return { success: false, error: '' }; // 템플릿의 중복 토스트/리다이렉트 방지
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('deposit');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error };
|
||||
},
|
||||
[mode, depositId, router]
|
||||
);
|
||||
@@ -102,11 +98,9 @@ export default function DepositDetailClientV2({
|
||||
if (!depositId) return { success: false, error: 'ID가 없습니다.' };
|
||||
|
||||
const result = await deleteDeposit(depositId);
|
||||
if (result.success) {
|
||||
invalidateDashboard('deposit');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: result.error };
|
||||
return result.success
|
||||
? { success: true }
|
||||
: { success: false, error: result.error };
|
||||
}, [depositId]);
|
||||
|
||||
// ===== 모드 변경 핸들러 =====
|
||||
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
Plus,
|
||||
Save,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@@ -71,7 +73,6 @@ import { deleteDeposit, updateDepositTypes, getDeposits } from './actions';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
|
||||
import { toast } from 'sonner';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useDateRange } from '@/hooks';
|
||||
import {
|
||||
extractUniqueOptions,
|
||||
@@ -82,13 +83,13 @@ import {
|
||||
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'depositDate', label: '입금일', sortable: true, copyable: true },
|
||||
{ key: 'accountName', label: '입금계좌', sortable: true, copyable: true },
|
||||
{ key: 'depositorName', label: '입금자명', sortable: true, copyable: true },
|
||||
{ key: 'depositAmount', label: '입금금액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
|
||||
{ key: 'note', label: '적요', sortable: true, copyable: true },
|
||||
{ key: 'depositType', label: '입금유형', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'depositDate', label: '입금일', sortable: true },
|
||||
{ key: 'accountName', label: '입금계좌', sortable: true },
|
||||
{ key: 'depositorName', label: '입금자명', sortable: true },
|
||||
{ key: 'depositAmount', label: '입금금액', className: 'text-right', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true },
|
||||
{ key: 'note', label: '적요', sortable: true },
|
||||
{ key: 'depositType', label: '입금유형', className: 'text-center', sortable: true },
|
||||
];
|
||||
|
||||
// ===== 컴포넌트 Props =====
|
||||
@@ -102,7 +103,7 @@ interface DepositManagementProps {
|
||||
};
|
||||
}
|
||||
|
||||
export function DepositManagement({ initialData, initialPagination: _initialPagination }: DepositManagementProps) {
|
||||
export function DepositManagement({ initialData, initialPagination }: DepositManagementProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
@@ -224,7 +225,6 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteDeposit(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('deposit');
|
||||
toast.success('입금 내역이 삭제되었습니다.');
|
||||
// 서버에서 재조회 (로컬 필터링 대신 - 페이지네이션 정합성 보장)
|
||||
await handleRefresh();
|
||||
|
||||
@@ -184,7 +184,7 @@ export async function getClients(): Promise<{
|
||||
success: boolean; data: { id: string; name: string }[]; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/clients', { size: 1000 }),
|
||||
url: buildApiUrl('/api/v1/clients', { per_page: 100 }),
|
||||
transform: (data: { data?: { id: number; name: string }[] } | { id: number; name: string }[]) => {
|
||||
type ClientApi = { id: number; name: string };
|
||||
const clients: ClientApi[] = Array.isArray(data) ? data : (data as { data?: ClientApi[] })?.data || [];
|
||||
|
||||
@@ -15,7 +15,6 @@ import { useState, useMemo, useCallback, useTransition, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import {
|
||||
Receipt,
|
||||
Calendar as CalendarIcon,
|
||||
@@ -59,7 +58,7 @@ import {
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard } from '@/components/organisms/MobileCard';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import type {
|
||||
ExpectedExpenseRecord,
|
||||
TransactionType,
|
||||
@@ -82,14 +81,15 @@ import {
|
||||
getClients,
|
||||
getBankAccounts,
|
||||
} from './actions';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import {
|
||||
TRANSACTION_TYPE_FILTER_OPTIONS,
|
||||
PAYMENT_STATUS_FILTER_OPTIONS,
|
||||
ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { extractUniqueOptions } from '../shared';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { applyFilters, textFilter, enumFilter } from '@/lib/utils/search';
|
||||
@@ -218,7 +218,7 @@ export function ExpectedExpenseManagement({
|
||||
}, [resetForm]);
|
||||
|
||||
// ===== 수정 다이얼로그 열기 =====
|
||||
const _handleOpenEditDialog = useCallback((item: ExpectedExpenseRecord) => {
|
||||
const handleOpenEditDialog = useCallback((item: ExpectedExpenseRecord) => {
|
||||
setEditingItem(item);
|
||||
setFormData({
|
||||
expectedPaymentDate: item.expectedPaymentDate,
|
||||
@@ -247,7 +247,6 @@ export function ExpectedExpenseManagement({
|
||||
// 수정
|
||||
const result = await updateExpectedExpense(editingItem.id, formData);
|
||||
if (result.success && result.data) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.map(item => item.id === editingItem.id ? result.data! : item));
|
||||
toast.success('미지급비용이 수정되었습니다.');
|
||||
setShowFormDialog(false);
|
||||
@@ -259,7 +258,6 @@ export function ExpectedExpenseManagement({
|
||||
// 등록
|
||||
const result = await createExpectedExpense(formData);
|
||||
if (result.success && result.data) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => [result.data!, ...prev]);
|
||||
toast.success('미지급비용이 등록되었습니다.');
|
||||
setShowFormDialog(false);
|
||||
@@ -280,7 +278,6 @@ export function ExpectedExpenseManagement({
|
||||
startTransition(async () => {
|
||||
const result = await deleteExpectedExpenses(selectedIds);
|
||||
if (result.success) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.filter(item => !selectedItems.has(item.id)));
|
||||
setSelectedItems(new Set());
|
||||
toast.success(`${result.deletedCount || selectedIds.length}건이 삭제되었습니다.`);
|
||||
@@ -484,7 +481,7 @@ export function ExpectedExpenseManagement({
|
||||
// ===== 액션 핸들러 =====
|
||||
// 상세페이지 없음 - 행 클릭 시 이동하지 않음
|
||||
|
||||
const _handleDeleteClick = useCallback((id: string) => {
|
||||
const handleDeleteClick = useCallback((id: string) => {
|
||||
setDeleteTargetId(id);
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
@@ -495,7 +492,6 @@ export function ExpectedExpenseManagement({
|
||||
startTransition(async () => {
|
||||
const result = await deleteExpectedExpense(deleteTargetId);
|
||||
if (result.success) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.filter(item => item.id !== deleteTargetId));
|
||||
setSelectedItems(prev => {
|
||||
const newSet = new Set(prev);
|
||||
@@ -526,7 +522,6 @@ export function ExpectedExpenseManagement({
|
||||
startTransition(async () => {
|
||||
const result = await updateExpectedPaymentDate(selectedIds, newExpectedDate);
|
||||
if (result.success) {
|
||||
invalidateDashboard('expectedExpense');
|
||||
setData(prev => prev.map(item =>
|
||||
selectedItems.has(item.id)
|
||||
? { ...item, expectedPaymentDate: newExpectedDate }
|
||||
@@ -560,11 +555,11 @@ export function ExpectedExpenseManagement({
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'expectedPaymentDate', label: '예상 지급일', sortable: true, copyable: true },
|
||||
{ key: 'accountSubject', label: '항목', sortable: true, copyable: true },
|
||||
{ key: 'amount', label: '지출금액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
|
||||
{ key: 'bankAccount', label: '계좌', sortable: true, copyable: true },
|
||||
{ key: 'expectedPaymentDate', label: '예상 지급일', sortable: true },
|
||||
{ key: 'accountSubject', label: '항목', sortable: true },
|
||||
{ key: 'amount', label: '지출금액', className: 'text-right', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true },
|
||||
{ key: 'bankAccount', label: '계좌', sortable: true },
|
||||
{ key: 'approvalStatus', label: '전자결재', className: 'text-center', sortable: true },
|
||||
], []);
|
||||
|
||||
@@ -587,9 +582,9 @@ export function ExpectedExpenseManagement({
|
||||
// 컬럼 순서: 체크박스 + 번호 + 예상 지급일 + 항목 + 지출금액 + 거래처 + 계좌 + 전자결재 + 작업
|
||||
const renderTableRow = useCallback((
|
||||
item: TableRowData,
|
||||
_index: number,
|
||||
_globalIndex: number,
|
||||
_handlers: SelectionHandlers & RowClickHandlers<TableRowData>
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<TableRowData>
|
||||
) => {
|
||||
// 월 헤더 행 (9개 컬럼)
|
||||
if (item.rowType === 'monthHeader') {
|
||||
@@ -699,9 +694,9 @@ export function ExpectedExpenseManagement({
|
||||
// ===== 모바일 카드 렌더링 =====
|
||||
const renderMobileCard = useCallback((
|
||||
item: TableRowData,
|
||||
_index: number,
|
||||
_globalIndex: number,
|
||||
_handlers: SelectionHandlers & RowClickHandlers<TableRowData>
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<TableRowData>
|
||||
) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
const onToggle = () => toggleSelection(item.id);
|
||||
@@ -1190,12 +1185,21 @@ export function ExpectedExpenseManagement({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>계정과목</Label>
|
||||
<AccountSubjectSelect
|
||||
value={formData.accountSubject || ''}
|
||||
<Select
|
||||
value={formData.accountSubject}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, accountSubject: value }))}
|
||||
placeholder="계정과목 선택"
|
||||
category="expense"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="계정과목 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter(opt => opt.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 계정과목 설정 모달 (공용)
|
||||
* 계정과목 설정 팝업
|
||||
*
|
||||
* - 계정과목 추가: 코드, 계정과목명, 분류 Select, 추가 버튼
|
||||
* - 검색: 검색 Input, 분류 필터 Select, 건수 표시
|
||||
* - 테이블: 코드 | 계정과목명 | 분류 | 부문 | 상태(사용중/미사용 토글) | 작업(삭제)
|
||||
* - 기본 계정과목표 일괄 생성 버튼
|
||||
* - 테이블: 코드 | 계정과목명 | 분류 | 상태(사용중/미사용 토글) | 작업(삭제)
|
||||
* - 버튼: 닫기
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2, Loader2, Database } from 'lucide-react';
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -55,16 +54,13 @@ import {
|
||||
createAccountSubject,
|
||||
updateAccountSubjectStatus,
|
||||
deleteAccountSubject,
|
||||
seedDefaultAccountSubjects,
|
||||
} from './actions';
|
||||
import type { AccountSubject, AccountSubjectCategory } from './types';
|
||||
import {
|
||||
ACCOUNT_CATEGORY_OPTIONS,
|
||||
ACCOUNT_CATEGORY_FILTER_OPTIONS,
|
||||
ACCOUNT_CATEGORY_LABELS,
|
||||
DEPARTMENT_TYPE_LABELS,
|
||||
} from './types';
|
||||
import type { DepartmentType } from './types';
|
||||
|
||||
interface AccountSubjectSettingModalProps {
|
||||
open: boolean;
|
||||
@@ -88,7 +84,6 @@ export function AccountSubjectSettingModal({
|
||||
// 데이터
|
||||
const [subjects, setSubjects] = useState<AccountSubject[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSeeding, setIsSeeding] = useState(false);
|
||||
|
||||
// 삭제 확인
|
||||
const [deleteTarget, setDeleteTarget] = useState<AccountSubject | null>(null);
|
||||
@@ -200,40 +195,10 @@ export function AccountSubjectSettingModal({
|
||||
}
|
||||
}, [deleteTarget, loadSubjects]);
|
||||
|
||||
// 기본 계정과목표 생성
|
||||
const handleSeedDefaults = useCallback(async () => {
|
||||
setIsSeeding(true);
|
||||
try {
|
||||
const result = await seedDefaultAccountSubjects();
|
||||
if (result.success) {
|
||||
const count = result.data?.inserted_count ?? 0;
|
||||
if (count > 0) {
|
||||
toast.success(`기본 계정과목 ${count}건이 생성되었습니다.`);
|
||||
} else {
|
||||
toast.info('이미 모든 기본 계정과목이 등록되어 있습니다.');
|
||||
}
|
||||
loadSubjects();
|
||||
} else {
|
||||
toast.error(result.error || '기본 계정과목 생성에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('기본 계정과목 생성 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSeeding(false);
|
||||
}
|
||||
}, [loadSubjects]);
|
||||
|
||||
// depth에 따른 들여쓰기
|
||||
const getIndentClass = (depth: number) => {
|
||||
if (depth === 1) return 'font-bold';
|
||||
if (depth === 2) return 'pl-4 font-medium';
|
||||
return 'pl-8';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[850px] max-h-[85vh] flex flex-col">
|
||||
<DialogContent className="sm:max-w-[750px] max-h-[85vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>계정과목 설정</DialogTitle>
|
||||
<DialogDescription className="sr-only">계정과목을 추가, 검색, 상태변경, 삭제합니다</DialogDescription>
|
||||
@@ -246,7 +211,7 @@ export function AccountSubjectSettingModal({
|
||||
label="코드"
|
||||
value={newCode}
|
||||
onChange={setNewCode}
|
||||
placeholder="예: 10100"
|
||||
placeholder="코드"
|
||||
/>
|
||||
<FormField
|
||||
label="계정과목명"
|
||||
@@ -308,23 +273,9 @@ export function AccountSubjectSettingModal({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{filteredSubjects.length}건
|
||||
<span className="text-sm text-muted-foreground ml-auto">
|
||||
{filteredSubjects.length}개
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9 ml-auto"
|
||||
onClick={handleSeedDefaults}
|
||||
disabled={isSeeding}
|
||||
>
|
||||
{isSeeding ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1" />
|
||||
) : (
|
||||
<Database className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
기본 계정과목 생성
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -338,36 +289,30 @@ export function AccountSubjectSettingModal({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">코드</TableHead>
|
||||
<TableHead className="w-[100px]">코드</TableHead>
|
||||
<TableHead>계정과목명</TableHead>
|
||||
<TableHead className="text-center w-[70px]">분류</TableHead>
|
||||
<TableHead className="text-center w-[60px]">부문</TableHead>
|
||||
<TableHead className="text-center w-[90px]">상태</TableHead>
|
||||
<TableHead className="text-center w-[50px]">작업</TableHead>
|
||||
<TableHead className="text-center w-[80px]">분류</TableHead>
|
||||
<TableHead className="text-center w-[100px]">상태</TableHead>
|
||||
<TableHead className="text-center w-[60px]">작업</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredSubjects.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground h-[100px]">
|
||||
계정과목이 없습니다. "기본 계정과목 생성" 버튼을 클릭하면 표준 계정과목표가 생성됩니다.
|
||||
<TableCell colSpan={5} className="text-center text-sm text-muted-foreground h-[100px]">
|
||||
계정과목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredSubjects.map((subject) => (
|
||||
<TableRow key={subject.id}>
|
||||
<TableCell className="text-sm font-mono">{subject.code}</TableCell>
|
||||
<TableCell className={`text-sm ${getIndentClass(subject.depth)}`}>
|
||||
{subject.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{subject.name}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{ACCOUNT_CATEGORY_LABELS[subject.category]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-xs text-muted-foreground">
|
||||
{DEPARTMENT_TYPE_LABELS[subject.departmentType as DepartmentType] || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant={subject.isActive ? 'default' : 'outline'}
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -57,12 +56,14 @@ import {
|
||||
getJournalDetail,
|
||||
updateJournalDetail,
|
||||
deleteJournalDetail,
|
||||
getAccountSubjects,
|
||||
getVendorList,
|
||||
} from './actions';
|
||||
import type {
|
||||
GeneralJournalRecord,
|
||||
JournalEntryRow,
|
||||
JournalSide,
|
||||
AccountSubject,
|
||||
VendorOption,
|
||||
} from './types';
|
||||
import { JOURNAL_SIDE_OPTIONS, JOURNAL_DIVISION_LABELS } from './types';
|
||||
@@ -108,6 +109,7 @@ export function JournalEditModal({
|
||||
const [accountNumber, setAccountNumber] = useState('');
|
||||
|
||||
// 옵션 데이터
|
||||
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
|
||||
const [vendors, setVendors] = useState<VendorOption[]>([]);
|
||||
|
||||
// 데이터 로드
|
||||
@@ -117,11 +119,15 @@ export function JournalEditModal({
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [detailRes, vendorsRes] = await Promise.all([
|
||||
const [detailRes, subjectsRes, vendorsRes] = await Promise.all([
|
||||
getJournalDetail(record.id),
|
||||
getAccountSubjects({ category: 'all' }),
|
||||
getVendorList(),
|
||||
]);
|
||||
|
||||
if (subjectsRes.success && subjectsRes.data) {
|
||||
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
|
||||
}
|
||||
if (vendorsRes.success && vendorsRes.data) {
|
||||
setVendors(vendorsRes.data);
|
||||
}
|
||||
@@ -355,14 +361,24 @@ export function JournalEditModal({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<AccountSubjectSelect
|
||||
value={row.accountSubjectId}
|
||||
<Select
|
||||
value={row.accountSubjectId || 'none'}
|
||||
onValueChange={(v) =>
|
||||
handleRowChange(row.id, 'accountSubjectId', v)
|
||||
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
|
||||
}
|
||||
size="sm"
|
||||
placeholder="선택"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{accountSubjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
|
||||
@@ -42,9 +42,8 @@ import {
|
||||
TableRow,
|
||||
TableFooter,
|
||||
} from '@/components/ui/table';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import { createManualJournal, getVendorList } from './actions';
|
||||
import type { JournalEntryRow, VendorOption } from './types';
|
||||
import { createManualJournal, getAccountSubjects, getVendorList } from './actions';
|
||||
import type { JournalEntryRow, JournalSide, AccountSubject, VendorOption } from './types';
|
||||
import { JOURNAL_SIDE_OPTIONS } from './types';
|
||||
import { getTodayString } from '@/lib/utils/date';
|
||||
|
||||
@@ -82,6 +81,7 @@ export function ManualJournalEntryModal({
|
||||
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
|
||||
|
||||
// 옵션 데이터
|
||||
const [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
|
||||
const [vendors, setVendors] = useState<VendorOption[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
@@ -94,7 +94,13 @@ export function ManualJournalEntryModal({
|
||||
setDescription('');
|
||||
setRows([createEmptyRow()]);
|
||||
|
||||
getVendorList().then((vendorsRes) => {
|
||||
Promise.all([
|
||||
getAccountSubjects({ category: 'all' }),
|
||||
getVendorList(),
|
||||
]).then(([subjectsRes, vendorsRes]) => {
|
||||
if (subjectsRes.success && subjectsRes.data) {
|
||||
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
|
||||
}
|
||||
if (vendorsRes.success && vendorsRes.data) {
|
||||
setVendors(vendorsRes.data);
|
||||
}
|
||||
@@ -266,14 +272,24 @@ export function ManualJournalEntryModal({
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<AccountSubjectSelect
|
||||
value={row.accountSubjectId}
|
||||
<Select
|
||||
value={row.accountSubjectId || 'none'}
|
||||
onValueChange={(v) =>
|
||||
handleRowChange(row.id, 'accountSubjectId', v)
|
||||
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
|
||||
}
|
||||
size="sm"
|
||||
placeholder="선택"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{accountSubjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Select
|
||||
|
||||
@@ -8,14 +8,69 @@ import type {
|
||||
GeneralJournalApiData,
|
||||
GeneralJournalSummary,
|
||||
GeneralJournalSummaryApiData,
|
||||
AccountSubject,
|
||||
AccountSubjectApiData,
|
||||
JournalEntryRow,
|
||||
VendorOption,
|
||||
} from './types';
|
||||
import {
|
||||
transformApiToFrontend,
|
||||
transformSummaryApi,
|
||||
transformAccountSubjectApi,
|
||||
} from './types';
|
||||
|
||||
// ===== Mock 데이터 (개발용) =====
|
||||
function generateMockJournalData(): GeneralJournalRecord[] {
|
||||
const descriptions = ['사무용품 구매', '직원 급여', '임대료 지급', '매출 입금', '교통비'];
|
||||
const journalDescs = ['복리후생비', '급여', '임차료', '매출', '여비교통비'];
|
||||
const divisions: Array<'deposit' | 'withdrawal' | 'transfer'> = ['deposit', 'withdrawal', 'transfer'];
|
||||
const sources: Array<'manual' | 'linked'> = ['manual', 'linked'];
|
||||
|
||||
return Array.from({ length: 10 }, (_, i) => {
|
||||
const division = divisions[i % 3];
|
||||
const depositAmount = division === 'deposit' ? 100000 * (i + 1) : 0;
|
||||
const withdrawalAmount = division === 'withdrawal' ? 80000 * (i + 1) : 0;
|
||||
return {
|
||||
id: String(5000 + i),
|
||||
date: '2025-12-12',
|
||||
division,
|
||||
amount: depositAmount || withdrawalAmount || 50000,
|
||||
description: descriptions[i % 5],
|
||||
journalDescription: journalDescs[i % 5],
|
||||
depositAmount,
|
||||
withdrawalAmount,
|
||||
balance: 1000000 - (i * 50000),
|
||||
debitAmount: [6000, 100000, 50000, 0, 30000][i % 5],
|
||||
creditAmount: [0, 0, 50000, 100000, 0][i % 5],
|
||||
source: sources[i % 4 === 0 ? 0 : 1],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generateMockSummary(): GeneralJournalSummary {
|
||||
return { totalCount: 10, depositCount: 4, depositAmount: 400000, withdrawalCount: 3, withdrawalAmount: 300000, journalCompleteCount: 7, journalIncompleteCount: 3 };
|
||||
}
|
||||
|
||||
function generateMockAccountSubjects(): AccountSubject[] {
|
||||
return [
|
||||
{ id: '101', code: '1010', name: '현금', category: 'asset', isActive: true },
|
||||
{ id: '102', code: '1020', name: '보통예금', category: 'asset', isActive: true },
|
||||
{ id: '201', code: '2010', name: '미지급금', category: 'liability', isActive: true },
|
||||
{ id: '401', code: '4010', name: '매출', category: 'revenue', isActive: true },
|
||||
{ id: '501', code: '5010', name: '복리후생비', category: 'expense', isActive: true },
|
||||
];
|
||||
}
|
||||
|
||||
function generateMockVendors(): VendorOption[] {
|
||||
return [
|
||||
{ id: '1', name: '삼성전자' },
|
||||
{ id: '2', name: '(주)한국물류' },
|
||||
{ id: '3', name: 'LG전자' },
|
||||
{ id: '4', name: '현대모비스' },
|
||||
{ id: '5', name: '(주)대한상사' },
|
||||
];
|
||||
}
|
||||
|
||||
// ===== 전표 목록 조회 =====
|
||||
export async function getJournalEntries(params: {
|
||||
startDate?: string;
|
||||
@@ -36,6 +91,15 @@ export async function getJournalEntries(params: {
|
||||
errorMessage: '전표 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || result.data.length === 0) {
|
||||
const mockData = generateMockJournalData();
|
||||
return {
|
||||
success: true as const,
|
||||
data: mockData,
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: mockData.length },
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -55,6 +119,10 @@ export async function getJournalSummary(params: {
|
||||
errorMessage: '전표 요약 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data) {
|
||||
return { success: true, data: generateMockSummary() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -83,6 +151,67 @@ export async function createManualJournal(data: {
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 목록 조회 =====
|
||||
export async function getAccountSubjects(params?: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
}): Promise<ActionResult<AccountSubject[]>> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/account-subjects', {
|
||||
search: params?.search || undefined,
|
||||
category: params?.category && params.category !== 'all' ? params.category : undefined,
|
||||
}),
|
||||
transform: (data: AccountSubjectApiData[]) => data.map(transformAccountSubjectApi),
|
||||
errorMessage: '계정과목 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data || result.data.length === 0) {
|
||||
return { success: true, data: generateMockAccountSubjects() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===== 계정과목 추가 =====
|
||||
export async function createAccountSubject(data: {
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
}): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/account-subjects'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
},
|
||||
errorMessage: '계정과목 추가에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 상태 토글 =====
|
||||
export async function updateAccountSubjectStatus(
|
||||
id: string,
|
||||
isActive: boolean
|
||||
): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/account-subjects/${id}/status`),
|
||||
method: 'PATCH',
|
||||
body: { is_active: isActive },
|
||||
errorMessage: '계정과목 상태 변경에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계정과목 삭제 =====
|
||||
export async function deleteAccountSubject(id: string): Promise<ActionResult> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/account-subjects/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '계정과목 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 분개 상세 조회 =====
|
||||
type JournalDetailData = {
|
||||
id: number;
|
||||
@@ -112,6 +241,26 @@ export async function getJournalDetail(id: string): Promise<ActionResult<Journal
|
||||
errorMessage: '분개 상세 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: Number(id),
|
||||
date: '2025-12-12',
|
||||
division: 'deposit',
|
||||
amount: 100000,
|
||||
description: '사무용품 구매',
|
||||
bank_name: '신한은행',
|
||||
account_number: '110-123-456789',
|
||||
journal_memo: '',
|
||||
rows: [
|
||||
{ id: 1, side: 'debit', account_subject_id: 501, account_subject_name: '복리후생비', vendor_id: 1, vendor_name: '삼성전자', debit_amount: 100000, credit_amount: 0, memo: '' },
|
||||
{ id: 2, side: 'credit', account_subject_id: 101, account_subject_name: '현금', vendor_id: null, vendor_name: '', debit_amount: 0, credit_amount: 100000, memo: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -159,5 +308,9 @@ export async function getVendorList(): Promise<ActionResult<VendorOption[]>> {
|
||||
errorMessage: '거래처 목록 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
// API 실패 또는 빈 응답 시 mock fallback (개발용)
|
||||
if (!result.success || !result.data || result.data.length === 0) {
|
||||
return { success: true, data: generateMockVendors() };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { MobileCard } from '@/components/organisms/MobileCard';
|
||||
import { ScrollableButtonGroup } from '@/components/atoms/ScrollableButtonGroup';
|
||||
import { getJournalEntries, getJournalSummary } from './actions';
|
||||
import { AccountSubjectSettingModal } from '@/components/accounting/common';
|
||||
import { AccountSubjectSettingModal } from './AccountSubjectSettingModal';
|
||||
import { ManualJournalEntryModal } from './ManualJournalEntryModal';
|
||||
import { JournalEditModal } from './JournalEditModal';
|
||||
import type { GeneralJournalRecord, GeneralJournalSummary, PeriodButtonValue } from './types';
|
||||
@@ -38,19 +38,18 @@ import {
|
||||
getPeriodDates,
|
||||
} from './types';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
|
||||
// ===== 테이블 컬럼 (기획서 기준 10개) =====
|
||||
const tableColumns = [
|
||||
{ key: 'date', label: '날짜', className: 'text-center w-[100px]', copyable: true },
|
||||
{ key: 'description', label: '적요', className: 'w-[140px]', copyable: true },
|
||||
{ key: 'depositAmount', label: '입금', className: 'text-right w-[100px]', copyable: true },
|
||||
{ key: 'withdrawalAmount', label: '출금', className: 'text-right w-[100px]', copyable: true },
|
||||
{ key: 'balance', label: '잔액', className: 'text-right w-[100px]', copyable: true },
|
||||
{ key: 'division', label: '구분', className: 'text-center w-[70px]', copyable: true },
|
||||
{ key: 'journalDescription', label: '내역', className: 'w-[100px]', copyable: true },
|
||||
{ key: 'debitAmount', label: '차변', className: 'text-right w-[90px]', copyable: true },
|
||||
{ key: 'creditAmount', label: '대변', className: 'text-right w-[90px]', copyable: true },
|
||||
{ key: 'date', label: '날짜', className: 'text-center w-[100px]' },
|
||||
{ key: 'description', label: '적요', className: 'w-[140px]' },
|
||||
{ key: 'depositAmount', label: '입금', className: 'text-right w-[100px]' },
|
||||
{ key: 'withdrawalAmount', label: '출금', className: 'text-right w-[100px]' },
|
||||
{ key: 'balance', label: '잔액', className: 'text-right w-[100px]' },
|
||||
{ key: 'division', label: '구분', className: 'text-center w-[70px]' },
|
||||
{ key: 'journalDescription', label: '내역', className: 'w-[100px]' },
|
||||
{ key: 'debitAmount', label: '차변', className: 'text-right w-[90px]' },
|
||||
{ key: 'creditAmount', label: '대변', className: 'text-right w-[90px]' },
|
||||
{ key: 'journalAction', label: '분개', className: 'text-center w-[70px]', sortable: false },
|
||||
];
|
||||
|
||||
@@ -152,14 +151,12 @@ export function GeneralJournalEntry() {
|
||||
const handleManualEntrySuccess = useCallback(() => {
|
||||
setShowManualEntry(false);
|
||||
loadData();
|
||||
invalidateDashboard('journalEntry');
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 분개 수정 완료 =====
|
||||
const handleJournalEditSuccess = useCallback(() => {
|
||||
setJournalEditTarget(null);
|
||||
loadData();
|
||||
invalidateDashboard('journalEntry');
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 합계 계산 =====
|
||||
|
||||
@@ -34,6 +34,30 @@ export const PERIOD_BUTTONS = [
|
||||
|
||||
export type PeriodButtonValue = (typeof PERIOD_BUTTONS)[number]['value'];
|
||||
|
||||
// ===== 계정과목 분류 =====
|
||||
export type AccountSubjectCategory = 'asset' | 'liability' | 'capital' | 'revenue' | 'expense';
|
||||
|
||||
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountSubjectCategory; label: string }[] = [
|
||||
{ value: 'asset', label: '자산' },
|
||||
{ value: 'liability', label: '부채' },
|
||||
{ value: 'capital', label: '자본' },
|
||||
{ value: 'revenue', label: '수익' },
|
||||
{ value: 'expense', label: '비용' },
|
||||
];
|
||||
|
||||
export const ACCOUNT_CATEGORY_FILTER_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
...ACCOUNT_CATEGORY_OPTIONS,
|
||||
];
|
||||
|
||||
export const ACCOUNT_CATEGORY_LABELS: Record<AccountSubjectCategory, string> = {
|
||||
asset: '자산',
|
||||
liability: '부채',
|
||||
capital: '자본',
|
||||
revenue: '수익',
|
||||
expense: '비용',
|
||||
};
|
||||
|
||||
// ===== 분개 구분 (차변/대변) =====
|
||||
export type JournalSide = 'debit' | 'credit';
|
||||
|
||||
@@ -97,6 +121,25 @@ export interface GeneralJournalSummaryApiData {
|
||||
journal_incomplete_count?: number;
|
||||
}
|
||||
|
||||
// ===== 계정과목 =====
|
||||
export interface AccountSubject {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
category: AccountSubjectCategory;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface AccountSubjectApiData {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
category: string;
|
||||
is_active: boolean | number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ===== 분개 행 =====
|
||||
export interface JournalEntryRow {
|
||||
id: string;
|
||||
@@ -173,6 +216,17 @@ export function transformSummaryApi(apiData: GeneralJournalSummaryApiData): Gene
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 계정과목 API → Frontend 변환 =====
|
||||
export function transformAccountSubjectApi(apiData: AccountSubjectApiData): AccountSubject {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
code: apiData.code,
|
||||
name: apiData.name,
|
||||
category: apiData.category as AccountSubjectCategory,
|
||||
isActive: Boolean(apiData.is_active),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 기간 버튼 → 날짜 변환 =====
|
||||
export function getPeriodDates(period: PeriodButtonValue): { start: string; end: string } {
|
||||
const today = new Date();
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
updateGiftCertificate,
|
||||
deleteGiftCertificate,
|
||||
} from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import {
|
||||
PURCHASE_PURPOSE_OPTIONS,
|
||||
ENTERTAINMENT_EXPENSE_OPTIONS,
|
||||
@@ -81,7 +80,6 @@ export function GiftCertificateDetail({
|
||||
: await updateGiftCertificate(id!, formData);
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('giftCertificate');
|
||||
toast.success(isNew ? '상품권이 등록되었습니다.' : '상품권이 수정되었습니다.');
|
||||
router.push('/ko/accounting/gift-certificates');
|
||||
} else {
|
||||
@@ -98,7 +96,6 @@ export function GiftCertificateDetail({
|
||||
try {
|
||||
const result = await deleteGiftCertificate(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('giftCertificate');
|
||||
toast.success('상품권이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/gift-certificates');
|
||||
} else {
|
||||
@@ -137,8 +134,8 @@ export function GiftCertificateDetail({
|
||||
label="일련번호"
|
||||
value={formData.serialNumber}
|
||||
onChange={(v) => handleChange('serialNumber', v)}
|
||||
placeholder="일련번호를 입력하세요"
|
||||
disabled={!isEditable}
|
||||
placeholder="자동 생성"
|
||||
disabled={!isNew}
|
||||
/>
|
||||
<FormField
|
||||
label="상품권명"
|
||||
|
||||
@@ -126,8 +126,6 @@ export async function getGiftCertificateSummary(params?: {
|
||||
holding_amount?: number;
|
||||
used_count?: number;
|
||||
used_amount?: number;
|
||||
entertainment_count?: number;
|
||||
entertainment_amount?: number;
|
||||
}) => ({
|
||||
totalCount: data.total_count ?? 0,
|
||||
totalAmount: data.total_amount ?? 0,
|
||||
@@ -135,8 +133,8 @@ export async function getGiftCertificateSummary(params?: {
|
||||
holdingAmount: data.holding_amount ?? 0,
|
||||
usedCount: data.used_count ?? 0,
|
||||
usedAmount: data.used_amount ?? 0,
|
||||
entertainmentCount: data.entertainment_count ?? 0,
|
||||
entertainmentAmount: data.entertainment_amount ?? 0,
|
||||
entertainmentCount: 0,
|
||||
entertainmentAmount: 0,
|
||||
}),
|
||||
errorMessage: '상품권 요약 조회에 실패했습니다.',
|
||||
});
|
||||
|
||||
@@ -44,10 +44,8 @@ import type {
|
||||
import {
|
||||
getGiftCertificates,
|
||||
getGiftCertificateSummary,
|
||||
deleteGiftCertificate,
|
||||
} from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
import { applyFilters, enumFilter } from '@/lib/utils/search';
|
||||
import { useDateRange } from '@/hooks';
|
||||
@@ -55,12 +53,12 @@ import { useDateRange } from '@/hooks';
|
||||
// ===== 테이블 컬럼 정의 (체크박스/No. 제외) =====
|
||||
const tableColumns = [
|
||||
{ key: 'rowNumber', label: '번호', className: 'text-center' },
|
||||
{ key: 'serialNumber', label: '일련번호', sortable: true, copyable: true },
|
||||
{ key: 'name', label: '상품권명', sortable: true, copyable: true },
|
||||
{ key: 'faceValue', label: '액면가', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'purchaseDate', label: '구입일', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'usedDate', label: '사용일', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'entertainmentExpense', label: '접대비', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'serialNumber', label: '일련번호', sortable: true },
|
||||
{ key: 'name', label: '상품권명', sortable: true },
|
||||
{ key: 'faceValue', label: '액면가', className: 'text-right', sortable: true },
|
||||
{ key: 'purchaseDate', label: '구입일', className: 'text-center', sortable: true },
|
||||
{ key: 'usedDate', label: '사용일', className: 'text-center', sortable: true },
|
||||
{ key: 'entertainmentExpense', label: '접대비', className: 'text-center', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
|
||||
];
|
||||
|
||||
@@ -87,7 +85,7 @@ export function GiftCertificateManagement() {
|
||||
// 필터 상태
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [entertainmentFilter, setEntertainmentFilter] = useState('all');
|
||||
const [, setSearchQuery] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 날짜 범위
|
||||
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth');
|
||||
@@ -125,7 +123,7 @@ export function GiftCertificateManagement() {
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleRowClick = useCallback((item: GiftCertificateRecord) => {
|
||||
router.push(`/accounting/gift-certificates?mode=view&id=${item.id}`);
|
||||
router.push(`/accounting/gift-certificates?mode=edit&id=${item.id}`);
|
||||
}, [router]);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
@@ -147,14 +145,6 @@ export function GiftCertificateManagement() {
|
||||
data,
|
||||
totalCount: data.length,
|
||||
}),
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteGiftCertificate(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('giftCertificate');
|
||||
await loadData();
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
},
|
||||
},
|
||||
|
||||
columns: tableColumns,
|
||||
@@ -369,7 +359,7 @@ export function GiftCertificateManagement() {
|
||||
);
|
||||
},
|
||||
}),
|
||||
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate, loadData]
|
||||
[data, summary, startDate, endDate, statusFilter, entertainmentFilter, handleRowClick, handleCreate]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getPresetStyle } from '@/lib/utils/status-config';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,7 +32,6 @@ import {
|
||||
deletePurchase,
|
||||
} from './actions';
|
||||
import { getClients } from '../VendorManagement/actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { toast } from 'sonner';
|
||||
import { formatNumber as formatAmount } from '@/lib/utils/amount';
|
||||
|
||||
@@ -62,7 +63,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [, setIsSaving] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
@@ -71,20 +72,20 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
const [purchaseNo, setPurchaseNo] = useState('');
|
||||
const [purchaseDate, setPurchaseDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||
const [vendorId, setVendorId] = useState('');
|
||||
const [, setVendorName] = useState('');
|
||||
const [vendorName, setVendorName] = useState('');
|
||||
// purchaseType 삭제됨 (기획서 P.109)
|
||||
const [items, setItems] = useState<PurchaseItem[]>([createEmptyItem()]);
|
||||
const [taxInvoiceReceived, setTaxInvoiceReceived] = useState(false);
|
||||
|
||||
// ===== 문서 관련 상태 (sourceDocument는 API에서 아직 미지원) =====
|
||||
const [sourceDocument, setSourceDocument] = useState<PurchaseRecord['sourceDocument']>(undefined);
|
||||
const [, setWithdrawalAccount] = useState<PurchaseRecord['withdrawalAccount']>(undefined);
|
||||
const [, setCreatedAt] = useState('');
|
||||
const [withdrawalAccount, setWithdrawalAccount] = useState<PurchaseRecord['withdrawalAccount']>(undefined);
|
||||
const [createdAt, setCreatedAt] = useState('');
|
||||
|
||||
// ===== 문서 열람 모달 상태 =====
|
||||
const [documentModalOpen, setDocumentModalOpen] = useState(false);
|
||||
const [modalData, setModalData] = useState<ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData | null>(null);
|
||||
const [, setIsModalLoading] = useState(false);
|
||||
const [isModalLoading, setIsModalLoading] = useState(false);
|
||||
const [approvalId, setApprovalId] = useState<string | undefined>(undefined);
|
||||
|
||||
// ===== 품목 관리 (공통 훅) =====
|
||||
@@ -259,7 +260,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
invalidateDashboard('purchase');
|
||||
toast.success(isNewMode ? '매입이 등록되었습니다.' : '매입이 수정되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -282,7 +282,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
const result = await deletePurchase(purchaseId);
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('purchase');
|
||||
toast.success('매입이 삭제되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
|
||||
@@ -21,8 +21,10 @@ import { toast } from 'sonner';
|
||||
import {
|
||||
Receipt,
|
||||
Save,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
// Badge, getPresetStyle removed (매입유형/연결문서 컬럼 삭제)
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -58,18 +60,17 @@ import {
|
||||
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
|
||||
} from './types';
|
||||
import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||
{ key: 'purchaseNo', label: '매입번호', sortable: true, copyable: true },
|
||||
{ key: 'purchaseDate', label: '매입일', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'purchaseNo', label: '매입번호', sortable: true },
|
||||
{ key: 'purchaseDate', label: '매입일', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
|
||||
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
|
||||
{ key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' },
|
||||
];
|
||||
|
||||
@@ -171,12 +172,12 @@ export function PurchaseManagement() {
|
||||
], [vendorOptions]);
|
||||
|
||||
// 필터 변경 핸들러
|
||||
const _handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||
setFilterValues(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
|
||||
// 필터 초기화 핸들러
|
||||
const _handleFilterReset = useCallback(() => {
|
||||
const handleFilterReset = useCallback(() => {
|
||||
setFilterValues({
|
||||
vendor: 'all',
|
||||
taxInvoiceReceived: 'all',
|
||||
@@ -252,7 +253,6 @@ export function PurchaseManagement() {
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deletePurchase(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('purchase');
|
||||
setPurchaseData(prev => prev.filter(item => item.id !== id));
|
||||
toast.success('매입이 삭제되었습니다.');
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { executeServerAction, type ActionResult } from '@/lib/api/execute-server
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { cookies } from 'next/headers';
|
||||
import type { VendorReceivables, CategoryType, ReceivablesListResponse, MemoUpdateRequest } from './types';
|
||||
import type { VendorReceivables, CategoryType, MonthlyAmount, ReceivablesListResponse, MemoUpdateRequest } from './types';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
interface CategoryAmountApi {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Download, FileText, Save, Loader2, RefreshCw, ChevronDown, ChevronUp }
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -65,7 +66,7 @@ const generateYearOptions = (): Array<{ value: number; label: string }> => {
|
||||
};
|
||||
|
||||
// ===== 카테고리 순서 (메모 제외) =====
|
||||
const _categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
|
||||
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
|
||||
|
||||
export function ReceivablesStatus({ highlightVendorId, initialData, initialSummary }: ReceivablesStatusProps) {
|
||||
const { canExport } = usePermission();
|
||||
@@ -81,7 +82,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
const [data, setData] = useState<VendorReceivables[]>(initialData?.items || []);
|
||||
const [originalOverdueMap, setOriginalOverdueMap] = useState<Map<string, boolean>>(new Map());
|
||||
const [originalMemoMap, setOriginalMemoMap] = useState<Map<string, string>>(new Map());
|
||||
const [, setSummary] = useState(initialSummary || {
|
||||
const [summary, setSummary] = useState(initialSummary || {
|
||||
totalCarryForward: 0,
|
||||
totalSales: 0,
|
||||
totalDeposits: 0,
|
||||
|
||||
@@ -34,7 +34,6 @@ import { LineItemsTable, useLineItems } from '@/components/organisms/LineItemsTa
|
||||
import { salesConfig } from './salesConfig';
|
||||
import type { SalesRecord, SalesItem } from './types';
|
||||
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { toast } from 'sonner';
|
||||
import { getClients } from '../VendorManagement/actions';
|
||||
|
||||
@@ -68,7 +67,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [, setIsSaving] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// ===== 거래처 목록 =====
|
||||
const [clients, setClients] = useState<ClientOption[]>([]);
|
||||
@@ -77,7 +76,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
const [salesNo, setSalesNo] = useState('');
|
||||
const [salesDate, setSalesDate] = useState(format(new Date(), 'yyyy-MM-dd'));
|
||||
const [vendorId, setVendorId] = useState('');
|
||||
const [, setVendorName] = useState('');
|
||||
const [vendorName, setVendorName] = useState('');
|
||||
const [items, setItems] = useState<SalesItem[]>([createEmptyItem()]);
|
||||
const [taxInvoiceIssued, setTaxInvoiceIssued] = useState(false);
|
||||
const [transactionStatementIssued, setTransactionStatementIssued] = useState(false);
|
||||
@@ -174,7 +173,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
}
|
||||
|
||||
if (result?.success) {
|
||||
invalidateDashboard('sales');
|
||||
toast.success(isNewMode ? '매출이 등록되었습니다.' : '매출이 수정되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -197,7 +195,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
const result = await deleteSale(salesId);
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('sales');
|
||||
toast.success('매출이 삭제되었습니다.');
|
||||
return { success: true };
|
||||
} else {
|
||||
|
||||
@@ -20,8 +20,10 @@ import { toast } from 'sonner';
|
||||
import {
|
||||
Receipt,
|
||||
Save,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
@@ -71,12 +73,12 @@ import { applyFilters, enumFilter } from '@/lib/utils/search';
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'salesNo', label: '매출번호', sortable: true, copyable: true },
|
||||
{ key: 'salesDate', label: '매출일', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
|
||||
{ key: 'totalSupplyAmount', label: '공급가액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'totalVat', label: '부가세', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'salesNo', label: '매출번호', sortable: true },
|
||||
{ key: 'salesDate', label: '매출일', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
|
||||
{ key: 'vat', label: '부가세', className: 'text-right', sortable: true },
|
||||
{ key: 'totalAmount', label: '합계금액', className: 'text-right', sortable: true },
|
||||
{ key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' },
|
||||
{ key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' },
|
||||
];
|
||||
@@ -195,7 +197,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
if ((!initialData || initialData.length === 0) && !isLoading) {
|
||||
loadData();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// ===== 페이지 변경 =====
|
||||
|
||||
@@ -224,7 +224,7 @@ export const API_STATUS_MAP: Record<string, SalesStatus> = {
|
||||
// API 응답 → 프론트엔드 타입 변환
|
||||
export function transformApiToFrontend(apiData: SaleApiData): SalesRecord {
|
||||
// 수주(Order)의 품목(items)을 매출 품목으로 변환
|
||||
const items: SalesItem[] = (apiData.order?.items ?? []).map((item, _index) => ({
|
||||
const items: SalesItem[] = (apiData.order?.items ?? []).map((item, index) => ({
|
||||
id: String(item.id),
|
||||
itemName: item.item_name ?? '',
|
||||
quantity: parseFloat(String(item.quantity)) || 0,
|
||||
|
||||
@@ -303,7 +303,7 @@ export async function searchVendorsForTaxInvoice(
|
||||
url: buildApiUrl('/api/v1/clients', {
|
||||
q: query || undefined,
|
||||
only_active: true,
|
||||
size: 1000,
|
||||
size: 100,
|
||||
}),
|
||||
transform: (data: { data: ClientApiData[] }) =>
|
||||
data.data.map((item) => ({
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
SORT_BY_OPTIONS,
|
||||
SORT_ORDER_OPTIONS,
|
||||
TAX_INVOICE_STATUS_MAP,
|
||||
createEmptyBusinessEntity,
|
||||
} from './types';
|
||||
import type {
|
||||
TaxInvoiceRecord,
|
||||
|
||||
@@ -53,11 +53,11 @@ import {
|
||||
updateJournalEntry,
|
||||
deleteJournalEntry,
|
||||
} from './actions';
|
||||
import { AccountSubjectSelect } from '@/components/accounting/common';
|
||||
import type { TaxInvoiceMgmtRecord, JournalEntryRow, JournalSide } from './types';
|
||||
import {
|
||||
TAB_OPTIONS,
|
||||
JOURNAL_SIDE_OPTIONS,
|
||||
ACCOUNT_SUBJECT_OPTIONS,
|
||||
} from './types';
|
||||
|
||||
interface JournalEntryModalProps {
|
||||
@@ -127,12 +127,12 @@ export function JournalEntryModal({
|
||||
}, [open, invoice]);
|
||||
|
||||
// 행 추가
|
||||
const _handleAddRow = useCallback(() => {
|
||||
const handleAddRow = useCallback(() => {
|
||||
setRows((prev) => [...prev, createEmptyRow()]);
|
||||
}, []);
|
||||
|
||||
// 행 삭제
|
||||
const _handleRemoveRow = useCallback((rowId: string) => {
|
||||
const handleRemoveRow = useCallback((rowId: string) => {
|
||||
setRows((prev) => {
|
||||
if (prev.length <= 1) return prev;
|
||||
return prev.filter((r) => r.id !== rowId);
|
||||
@@ -288,14 +288,25 @@ export function JournalEntryModal({
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<AccountSubjectSelect
|
||||
<Select
|
||||
value={row.accountSubject}
|
||||
onValueChange={(v) =>
|
||||
handleRowChange(row.id, 'accountSubject', v)
|
||||
}
|
||||
placeholder="선택"
|
||||
size="sm"
|
||||
/>
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.filter((o) => o.value).map(
|
||||
(opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="p-1">
|
||||
<Input
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { FormField } from '@/components/molecules/FormField';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -200,14 +199,12 @@ export function ManualEntryModal({
|
||||
onChange={(value) => handleChange('vendorName', value)}
|
||||
placeholder="공급자명"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-sm">사업자번호</Label>
|
||||
<BusinessNumberInput
|
||||
value={formData.vendorBusinessNumber}
|
||||
onChange={(value) => handleChange('vendorBusinessNumber', value)}
|
||||
placeholder="000-00-00000"
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
label="사업자번호"
|
||||
value={formData.vendorBusinessNumber}
|
||||
onChange={(value) => handleChange('vendorBusinessNumber', value)}
|
||||
placeholder="사업자번호"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import type {
|
||||
TaxInvoiceMgmtApiData,
|
||||
TaxInvoiceSummary,
|
||||
TaxInvoiceSummaryApiData,
|
||||
CardHistoryApiData,
|
||||
CardHistoryRecord,
|
||||
CardHistoryApiData,
|
||||
ManualEntryFormData,
|
||||
JournalEntryRow,
|
||||
} from './types';
|
||||
@@ -20,6 +20,17 @@ import {
|
||||
transformSummaryApi,
|
||||
} from './types';
|
||||
|
||||
// ===== 세금계산서 목록 Mock =====
|
||||
// TODO: 실제 API 연동 시 Mock 제거
|
||||
const MOCK_INVOICES: TaxInvoiceMgmtRecord[] = [
|
||||
{ id: '1', division: 'sales', writeDate: '2026-01-15', issueDate: '2026-01-16', vendorName: '(주)삼성전자', vendorBusinessNumber: '124-81-00998', taxType: 'taxable', itemName: '전자부품', supplyAmount: 500000, taxAmount: 50000, totalAmount: 550000, receiptType: 'receipt', documentNumber: 'TI-001', status: 'journalized', source: 'hometax', memo: '' },
|
||||
{ id: '2', division: 'sales', writeDate: '2026-01-20', issueDate: '2026-01-20', vendorName: '현대건설(주)', vendorBusinessNumber: '211-85-12345', taxType: 'taxable', itemName: '건축자재', supplyAmount: 1200000, taxAmount: 120000, totalAmount: 1320000, receiptType: 'claim', documentNumber: 'TI-002', status: 'pending', source: 'hometax', memo: '' },
|
||||
{ id: '3', division: 'sales', writeDate: '2026-02-03', issueDate: null, vendorName: '(주)한국사무용품', vendorBusinessNumber: '107-86-55432', taxType: 'taxable', itemName: '사무용품', supplyAmount: 300000, taxAmount: 30000, totalAmount: 330000, receiptType: 'receipt', documentNumber: '', status: 'pending', source: 'manual', memo: '수기 입력' },
|
||||
{ id: '4', division: 'purchase', writeDate: '2026-01-10', issueDate: '2026-01-11', vendorName: 'CJ대한통운', vendorBusinessNumber: '110-81-28388', taxType: 'taxable', itemName: '운송비', supplyAmount: 40000, taxAmount: 4000, totalAmount: 44000, receiptType: 'receipt', documentNumber: 'TI-003', status: 'journalized', source: 'hometax', memo: '' },
|
||||
{ id: '5', division: 'purchase', writeDate: '2026-02-01', issueDate: '2026-02-01', vendorName: '스타벅스 역삼역점', vendorBusinessNumber: '201-86-99012', taxType: 'tax_free', itemName: '복리후생', supplyAmount: 14000, taxAmount: 1400, totalAmount: 15400, receiptType: 'receipt', documentNumber: 'TI-004', status: 'pending', source: 'hometax', memo: '' },
|
||||
{ id: '6', division: 'purchase', writeDate: '2026-02-10', issueDate: null, vendorName: '(주)코스트코코리아', vendorBusinessNumber: '301-81-67890', taxType: 'taxable', itemName: '비품', supplyAmount: 200000, taxAmount: 20000, totalAmount: 220000, receiptType: 'claim', documentNumber: '', status: 'error', source: 'manual', memo: '수기 입력' },
|
||||
];
|
||||
|
||||
// ===== 세금계산서 목록 조회 =====
|
||||
export async function getTaxInvoices(params: {
|
||||
division?: string;
|
||||
@@ -30,39 +41,45 @@ export async function getTaxInvoices(params: {
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}) {
|
||||
// frontend 'purchase' → backend 'purchases'
|
||||
const direction = params.division === 'purchase' ? 'purchases' : params.division;
|
||||
|
||||
return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
|
||||
url: buildApiUrl('/api/v1/tax-invoices', {
|
||||
direction,
|
||||
issue_date_from: params.startDate,
|
||||
issue_date_to: params.endDate,
|
||||
corp_name: params.vendorSearch || undefined,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
}),
|
||||
transform: transformApiToFrontend,
|
||||
errorMessage: '세금계산서 목록 조회에 실패했습니다.',
|
||||
});
|
||||
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||
// return executePaginatedAction<TaxInvoiceMgmtApiData, TaxInvoiceMgmtRecord>({
|
||||
// url: buildApiUrl('/api/v1/tax-invoices', { ... }),
|
||||
// transform: transformApiToFrontend,
|
||||
// errorMessage: '세금계산서 목록 조회에 실패했습니다.',
|
||||
// });
|
||||
const filtered = MOCK_INVOICES.filter((inv) => inv.division === (params.division || 'sales'));
|
||||
return {
|
||||
success: true as const,
|
||||
data: filtered,
|
||||
error: undefined as string | undefined,
|
||||
pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: filtered.length },
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 세금계산서 요약 조회 =====
|
||||
export async function getTaxInvoiceSummary(params: {
|
||||
export async function getTaxInvoiceSummary(_params: {
|
||||
dateType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
vendorSearch?: string;
|
||||
}): Promise<ActionResult<TaxInvoiceSummary>> {
|
||||
return executeServerAction<TaxInvoiceSummaryApiData, TaxInvoiceSummary>({
|
||||
url: buildApiUrl('/api/v1/tax-invoices/summary', {
|
||||
issue_date_from: params.startDate,
|
||||
issue_date_to: params.endDate,
|
||||
corp_name: params.vendorSearch || undefined,
|
||||
}),
|
||||
transform: transformSummaryApi,
|
||||
errorMessage: '세금계산서 요약 조회에 실패했습니다.',
|
||||
});
|
||||
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||
// return executeServerAction({ ... });
|
||||
const sales = MOCK_INVOICES.filter((inv) => inv.division === 'sales');
|
||||
const purchase = MOCK_INVOICES.filter((inv) => inv.division === 'purchase');
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
salesSupplyAmount: sales.reduce((s, i) => s + i.supplyAmount, 0),
|
||||
salesTaxAmount: sales.reduce((s, i) => s + i.taxAmount, 0),
|
||||
salesTotalAmount: sales.reduce((s, i) => s + i.totalAmount, 0),
|
||||
salesCount: sales.length,
|
||||
purchaseSupplyAmount: purchase.reduce((s, i) => s + i.supplyAmount, 0),
|
||||
purchaseTaxAmount: purchase.reduce((s, i) => s + i.taxAmount, 0),
|
||||
purchaseTotalAmount: purchase.reduce((s, i) => s + i.totalAmount, 0),
|
||||
purchaseCount: purchase.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 세금계산서 수기 등록 =====
|
||||
@@ -79,24 +96,35 @@ export async function createTaxInvoice(
|
||||
}
|
||||
|
||||
// ===== 카드 내역 조회 =====
|
||||
export async function getCardHistory(params: {
|
||||
// TODO: 실제 API 연동 시 Mock 제거
|
||||
const MOCK_CARD_HISTORY: CardHistoryRecord[] = [
|
||||
{ id: '1', transactionDate: '2026-01-20', merchantName: '(주)삼성전자', amount: 550000, approvalNumber: 'AP-20260120-001', businessNumber: '124-81-00998' },
|
||||
{ id: '2', transactionDate: '2026-01-25', merchantName: '현대오일뱅크 강남점', amount: 82500, approvalNumber: 'AP-20260125-003', businessNumber: '211-85-12345' },
|
||||
{ id: '3', transactionDate: '2026-02-03', merchantName: '(주)한국사무용품', amount: 330000, approvalNumber: 'AP-20260203-007', businessNumber: '107-86-55432' },
|
||||
{ id: '4', transactionDate: '2026-02-10', merchantName: 'CJ대한통운', amount: 44000, approvalNumber: 'AP-20260210-012', businessNumber: '110-81-28388' },
|
||||
{ id: '5', transactionDate: '2026-02-14', merchantName: '스타벅스 역삼역점', amount: 15400, approvalNumber: 'AP-20260214-019', businessNumber: '201-86-99012' },
|
||||
];
|
||||
|
||||
export async function getCardHistory(_params: {
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
search?: string;
|
||||
page?: number;
|
||||
perPage?: number;
|
||||
}) {
|
||||
return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
|
||||
url: buildApiUrl('/api/v1/card-transactions', {
|
||||
start_date: params.startDate,
|
||||
end_date: params.endDate,
|
||||
search: params.search || undefined,
|
||||
page: params.page,
|
||||
per_page: params.perPage,
|
||||
}),
|
||||
transform: transformCardHistoryApi,
|
||||
errorMessage: '카드 내역 조회에 실패했습니다.',
|
||||
});
|
||||
}): Promise<ActionResult<CardHistoryRecord[]>> {
|
||||
// TODO: 실제 API 연동 시 아래 코드로 교체
|
||||
// return executePaginatedAction<CardHistoryApiData, CardHistoryRecord>({
|
||||
// url: buildApiUrl('/api/v1/card-transactions/history', {
|
||||
// start_date: _params.startDate,
|
||||
// end_date: _params.endDate,
|
||||
// search: _params.search || undefined,
|
||||
// page: _params.page,
|
||||
// per_page: _params.perPage,
|
||||
// }),
|
||||
// transform: transformCardHistoryApi,
|
||||
// errorMessage: '카드 내역 조회에 실패했습니다.',
|
||||
// });
|
||||
return { success: true, data: MOCK_CARD_HISTORY };
|
||||
}
|
||||
|
||||
// ===== 분개 내역 조회 =====
|
||||
|
||||
@@ -103,18 +103,18 @@ const excelColumns: ExcelColumn<TaxInvoiceMgmtRecord & Record<string, unknown>>[
|
||||
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns = [
|
||||
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'issueDate', label: '발급일자', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true, copyable: true },
|
||||
{ key: 'vendorBusinessNumber', label: '사업자번호\n(주민번호)', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'taxType', label: '과세형태', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'itemName', label: '품목', sortable: true, copyable: true },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'taxAmount', label: '세액', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'totalAmount', label: '합계', className: 'text-right', sortable: true, copyable: true },
|
||||
{ key: 'receiptType', label: '영수청구', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'documentType', label: '문서형태', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'issueType', label: '발급형태', className: 'text-center', sortable: true, copyable: true },
|
||||
{ key: 'writeDate', label: '작성일자', className: 'text-center', sortable: true },
|
||||
{ key: 'issueDate', label: '발급일자', className: 'text-center', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처', sortable: true },
|
||||
{ key: 'vendorBusinessNumber', label: '사업자번호\n(주민번호)', className: 'text-center', sortable: true },
|
||||
{ key: 'taxType', label: '과세형태', className: 'text-center', sortable: true },
|
||||
{ key: 'itemName', label: '품목', sortable: true },
|
||||
{ key: 'supplyAmount', label: '공급가액', className: 'text-right', sortable: true },
|
||||
{ key: 'taxAmount', label: '세액', className: 'text-right', sortable: true },
|
||||
{ key: 'totalAmount', label: '합계', className: 'text-right', sortable: true },
|
||||
{ key: 'receiptType', label: '영수청구', className: 'text-center', sortable: true },
|
||||
{ key: 'documentType', label: '문서형태', className: 'text-center', sortable: true },
|
||||
{ key: 'issueType', label: '발급형태', className: 'text-center', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center', sortable: true },
|
||||
{ key: 'journal', label: '분개', className: 'text-center w-[80px]' },
|
||||
];
|
||||
|
||||
@@ -45,14 +45,12 @@ export const RECEIPT_TYPE_LABELS: Record<ReceiptType, string> = {
|
||||
};
|
||||
|
||||
// ===== 세금계산서 상태 =====
|
||||
export type InvoiceStatus = 'draft' | 'issued' | 'sent' | 'cancelled' | 'failed';
|
||||
export type InvoiceStatus = 'pending' | 'journalized' | 'error';
|
||||
|
||||
export const INVOICE_STATUS_MAP: Record<InvoiceStatus, { label: string; color: string }> = {
|
||||
draft: { label: '임시저장', color: 'bg-gray-100 text-gray-700' },
|
||||
issued: { label: '발급완료', color: 'bg-blue-100 text-blue-700' },
|
||||
sent: { label: '전송완료', color: 'bg-green-100 text-green-700' },
|
||||
cancelled: { label: '취소', color: 'bg-red-100 text-red-700' },
|
||||
failed: { label: '실패', color: 'bg-orange-100 text-orange-700' },
|
||||
pending: { label: '미분개', color: 'bg-yellow-100 text-yellow-700' },
|
||||
journalized: { label: '분개완료', color: 'bg-green-100 text-green-700' },
|
||||
error: { label: '오류', color: 'bg-red-100 text-red-700' },
|
||||
};
|
||||
|
||||
// ===== 소스 구분 (수기/홈택스) =====
|
||||
@@ -89,25 +87,24 @@ export interface TaxInvoiceMgmtRecord {
|
||||
memo: string;
|
||||
}
|
||||
|
||||
// ===== API 응답 타입 (백엔드 TaxInvoice 모델 기준) =====
|
||||
// ===== API 응답 타입 (snake_case) =====
|
||||
export interface TaxInvoiceMgmtApiData {
|
||||
id: number;
|
||||
direction: string;
|
||||
supplier_corp_num: string | null;
|
||||
supplier_corp_name: string | null;
|
||||
buyer_corp_num: string | null;
|
||||
buyer_corp_name: string | null;
|
||||
division: string;
|
||||
write_date: string;
|
||||
issue_date: string | null;
|
||||
vendor_name: string;
|
||||
vendor_business_number: string;
|
||||
tax_type: string;
|
||||
item_name: string;
|
||||
supply_amount: string | number;
|
||||
tax_amount: string | number;
|
||||
total_amount: string | number;
|
||||
receipt_type: string;
|
||||
document_number: string;
|
||||
status: string;
|
||||
invoice_type: string | null;
|
||||
issue_type: string | null;
|
||||
nts_confirm_num: string | null;
|
||||
description: string | null;
|
||||
barobill_invoice_id: string | null;
|
||||
items: Array<{ name?: string; [key: string]: unknown }> | null;
|
||||
source: string;
|
||||
memo: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -124,20 +121,15 @@ export interface TaxInvoiceSummary {
|
||||
purchaseCount: number;
|
||||
}
|
||||
|
||||
// 백엔드 summary API는 by_direction 중첩 구조로 응답
|
||||
interface DirectionSummary {
|
||||
count: number;
|
||||
supply_amount: number;
|
||||
tax_amount: number;
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
export interface TaxInvoiceSummaryApiData {
|
||||
by_direction: {
|
||||
sales: DirectionSummary;
|
||||
purchases: DirectionSummary;
|
||||
};
|
||||
by_status: Record<string, number>;
|
||||
sales_supply_amount: number;
|
||||
sales_tax_amount: number;
|
||||
sales_total_amount: number;
|
||||
sales_count: number;
|
||||
purchase_supply_amount: number;
|
||||
purchase_tax_amount: number;
|
||||
purchase_total_amount: number;
|
||||
purchase_count: number;
|
||||
}
|
||||
|
||||
// ===== 분개 항목 =====
|
||||
@@ -173,12 +165,11 @@ export interface CardHistoryRecord {
|
||||
|
||||
export interface CardHistoryApiData {
|
||||
id: number;
|
||||
used_at: string;
|
||||
transaction_date: string;
|
||||
merchant_name: string;
|
||||
amount: string | number;
|
||||
approval_number?: string;
|
||||
business_number?: string;
|
||||
description?: string | null;
|
||||
approval_number: string;
|
||||
business_number: string;
|
||||
}
|
||||
|
||||
// ===== 수기 입력 폼 데이터 =====
|
||||
@@ -211,66 +202,40 @@ export const ACCOUNT_SUBJECT_OPTIONS = [
|
||||
];
|
||||
|
||||
// ===== API → Frontend 변환 =====
|
||||
const VALID_STATUSES: InvoiceStatus[] = ['draft', 'issued', 'sent', 'cancelled', 'failed'];
|
||||
|
||||
const INVOICE_TYPE_TO_TAX_TYPE: Record<string, TaxType> = {
|
||||
tax_invoice: 'taxable',
|
||||
modified: 'taxable',
|
||||
invoice: 'tax_free',
|
||||
};
|
||||
|
||||
const ISSUE_TYPE_TO_RECEIPT_TYPE: Record<string, ReceiptType> = {
|
||||
receipt: 'receipt',
|
||||
claim: 'claim',
|
||||
};
|
||||
|
||||
export function transformApiToFrontend(apiData: TaxInvoiceMgmtApiData): TaxInvoiceMgmtRecord {
|
||||
const isSales = apiData.direction === 'sales';
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
division: isSales ? 'sales' : 'purchase',
|
||||
writeDate: apiData.issue_date || apiData.created_at?.split('T')[0] || '',
|
||||
division: apiData.division as InvoiceTab,
|
||||
writeDate: apiData.write_date,
|
||||
issueDate: apiData.issue_date,
|
||||
vendorName: isSales
|
||||
? (apiData.buyer_corp_name || '')
|
||||
: (apiData.supplier_corp_name || ''),
|
||||
vendorBusinessNumber: isSales
|
||||
? (apiData.buyer_corp_num || '')
|
||||
: (apiData.supplier_corp_num || ''),
|
||||
taxType: INVOICE_TYPE_TO_TAX_TYPE[apiData.invoice_type || ''] || 'taxable',
|
||||
itemName: apiData.items?.[0]?.name || apiData.description || '',
|
||||
supplyAmount: Number(apiData.supply_amount) || 0,
|
||||
taxAmount: Number(apiData.tax_amount) || 0,
|
||||
totalAmount: Number(apiData.total_amount) || 0,
|
||||
receiptType: ISSUE_TYPE_TO_RECEIPT_TYPE[apiData.issue_type || ''] || 'receipt',
|
||||
documentNumber: apiData.nts_confirm_num || '',
|
||||
status: VALID_STATUSES.includes(apiData.status as InvoiceStatus)
|
||||
? (apiData.status as InvoiceStatus)
|
||||
: 'draft',
|
||||
source: apiData.barobill_invoice_id ? 'hometax' : 'manual',
|
||||
memo: apiData.description || '',
|
||||
vendorName: apiData.vendor_name,
|
||||
vendorBusinessNumber: apiData.vendor_business_number,
|
||||
taxType: apiData.tax_type as TaxType,
|
||||
itemName: apiData.item_name,
|
||||
supplyAmount: Number(apiData.supply_amount),
|
||||
taxAmount: Number(apiData.tax_amount),
|
||||
totalAmount: Number(apiData.total_amount),
|
||||
receiptType: apiData.receipt_type as ReceiptType,
|
||||
documentNumber: apiData.document_number,
|
||||
status: apiData.status as InvoiceStatus,
|
||||
source: apiData.source as InvoiceSource,
|
||||
memo: apiData.memo || '',
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Frontend → API 변환 =====
|
||||
export function transformFrontendToApi(data: ManualEntryFormData): Record<string, unknown> {
|
||||
const isSales = data.division === 'sales';
|
||||
return {
|
||||
direction: isSales ? 'sales' : 'purchases',
|
||||
issue_type: 'normal',
|
||||
issue_date: data.writeDate,
|
||||
// 매출: 거래처=공급받는자(buyer), 매입: 거래처=공급자(supplier)
|
||||
// DB 컬럼이 NOT NULL이므로 빈 문자열로 전송
|
||||
supplier_corp_name: isSales ? '' : data.vendorName,
|
||||
supplier_corp_num: isSales ? '' : data.vendorBusinessNumber,
|
||||
buyer_corp_name: isSales ? data.vendorName : '',
|
||||
buyer_corp_num: isSales ? data.vendorBusinessNumber : '',
|
||||
division: data.division,
|
||||
write_date: data.writeDate,
|
||||
vendor_name: data.vendorName,
|
||||
vendor_business_number: data.vendorBusinessNumber,
|
||||
supply_amount: data.supplyAmount,
|
||||
tax_amount: data.taxAmount,
|
||||
total_amount: data.totalAmount,
|
||||
invoice_type: data.taxType === 'tax_free' ? 'invoice' : 'tax_invoice',
|
||||
description: data.memo || null,
|
||||
items: data.itemName ? [{ name: data.itemName, amount: data.supplyAmount }] : [],
|
||||
item_name: data.itemName,
|
||||
tax_type: data.taxType,
|
||||
memo: data.memo || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -278,28 +243,24 @@ export function transformFrontendToApi(data: ManualEntryFormData): Record<string
|
||||
export function transformCardHistoryApi(apiData: CardHistoryApiData): CardHistoryRecord {
|
||||
return {
|
||||
id: String(apiData.id),
|
||||
transactionDate: apiData.used_at,
|
||||
transactionDate: apiData.transaction_date,
|
||||
merchantName: apiData.merchant_name,
|
||||
amount: Number(apiData.amount),
|
||||
approvalNumber: apiData.approval_number || '',
|
||||
businessNumber: apiData.business_number || '',
|
||||
approvalNumber: apiData.approval_number,
|
||||
businessNumber: apiData.business_number,
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 요약 API → Frontend 변환 =====
|
||||
const EMPTY_DIRECTION: DirectionSummary = { count: 0, supply_amount: 0, tax_amount: 0, total_amount: 0 };
|
||||
|
||||
export function transformSummaryApi(apiData: TaxInvoiceSummaryApiData): TaxInvoiceSummary {
|
||||
const sales = apiData.by_direction?.sales || EMPTY_DIRECTION;
|
||||
const purchases = apiData.by_direction?.purchases || EMPTY_DIRECTION;
|
||||
return {
|
||||
salesSupplyAmount: sales.supply_amount,
|
||||
salesTaxAmount: sales.tax_amount,
|
||||
salesTotalAmount: sales.total_amount,
|
||||
salesCount: sales.count,
|
||||
purchaseSupplyAmount: purchases.supply_amount,
|
||||
purchaseTaxAmount: purchases.tax_amount,
|
||||
purchaseTotalAmount: purchases.total_amount,
|
||||
purchaseCount: purchases.count,
|
||||
salesSupplyAmount: apiData.sales_supply_amount,
|
||||
salesTaxAmount: apiData.sales_tax_amount,
|
||||
salesTotalAmount: apiData.sales_total_amount,
|
||||
salesCount: apiData.sales_count,
|
||||
purchaseSupplyAmount: apiData.purchase_supply_amount,
|
||||
purchaseTaxAmount: apiData.purchase_tax_amount,
|
||||
purchaseTotalAmount: apiData.purchase_total_amount,
|
||||
purchaseCount: apiData.purchase_count,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,12 +36,12 @@ import { usePermission } from '@/hooks/usePermission';
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: 'No.', className: 'text-center w-[60px]' },
|
||||
{ key: 'vendorName', label: '거래처명', sortable: true, copyable: true },
|
||||
{ key: 'carryoverBalance', label: '이월잔액', className: 'text-right w-[120px]', sortable: true, copyable: true },
|
||||
{ key: 'sales', label: '매출', className: 'text-right w-[120px]', sortable: true, copyable: true },
|
||||
{ key: 'collection', label: '수금', className: 'text-right w-[120px]', sortable: true, copyable: true },
|
||||
{ key: 'balance', label: '잔액', className: 'text-right w-[120px]', sortable: true, copyable: true },
|
||||
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처명', sortable: true },
|
||||
{ key: 'carryoverBalance', label: '이월잔액', className: 'text-right w-[120px]', sortable: true },
|
||||
{ key: 'sales', label: '매출', className: 'text-right w-[120px]', sortable: true },
|
||||
{ key: 'collection', label: '수금', className: 'text-right w-[120px]', sortable: true },
|
||||
{ key: 'balance', label: '잔액', className: 'text-right w-[120px]', sortable: true },
|
||||
{ key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]', sortable: true },
|
||||
];
|
||||
|
||||
// ===== 엑셀 컬럼 정의 =====
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
import { Plus, Trash2, Upload } from 'lucide-react';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
@@ -194,7 +194,6 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
return { success: false, error: result.message || '저장에 실패했습니다.' };
|
||||
}
|
||||
|
||||
invalidateDashboard('client');
|
||||
router.refresh();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
@@ -215,7 +214,6 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
return { success: false, error: result.message || '삭제에 실패했습니다.' };
|
||||
}
|
||||
|
||||
invalidateDashboard('client');
|
||||
router.refresh();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
||||
@@ -42,9 +42,15 @@ import {
|
||||
type RowClickHandlers,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||
import { toast } from 'sonner';
|
||||
import { deleteClient } from './actions';
|
||||
import type {
|
||||
Vendor,
|
||||
VendorCategory,
|
||||
CreditRating,
|
||||
TransactionGrade,
|
||||
BadDebtStatus,
|
||||
VendorStatus,
|
||||
SortOption,
|
||||
} from './types';
|
||||
import {
|
||||
@@ -69,7 +75,7 @@ interface VendorManagementClientProps {
|
||||
initialTotal: number;
|
||||
}
|
||||
|
||||
export function VendorManagementClient({ initialData, initialTotal: _initialTotal }: VendorManagementClientProps) {
|
||||
export function VendorManagementClient({ initialData, initialTotal }: VendorManagementClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
@@ -197,13 +203,13 @@ export function VendorManagementClient({ initialData, initialTotal: _initialTota
|
||||
// ===== 테이블 컬럼 =====
|
||||
const tableColumns: TableColumn[] = useMemo(() => [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'category', label: '구분', className: 'text-center w-[100px]', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처명', sortable: true, copyable: true },
|
||||
{ key: 'purchasePaymentDay', label: '매입 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
|
||||
{ key: 'salesPaymentDay', label: '매출 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
|
||||
{ key: 'category', label: '구분', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처명', sortable: true },
|
||||
{ key: 'purchasePaymentDay', label: '매입 결제일', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'salesPaymentDay', label: '매출 결제일', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'creditRating', label: '신용등급', className: 'text-center w-[90px]', sortable: true },
|
||||
{ key: 'transactionGrade', label: '거래등급', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]', sortable: true, copyable: true },
|
||||
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]', sortable: true },
|
||||
{ key: 'badDebtStatus', label: '악성채권', className: 'text-center w-[90px]', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center w-[80px]', sortable: true },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[150px]' },
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { formatNumber } from '@/lib/utils/amount';
|
||||
@@ -56,13 +55,13 @@ import { toast } from 'sonner';
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
const tableColumns = [
|
||||
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||
{ key: 'category', label: '구분', className: 'text-center w-[100px]', sortable: true, copyable: true },
|
||||
{ key: 'vendorName', label: '거래처명', sortable: true, copyable: true },
|
||||
{ key: 'purchasePaymentDay', label: '매입 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
|
||||
{ key: 'salesPaymentDay', label: '매출 결제일', className: 'text-center w-[100px]', sortable: true, copyable: true },
|
||||
{ key: 'category', label: '구분', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'vendorName', label: '거래처명', sortable: true },
|
||||
{ key: 'purchasePaymentDay', label: '매입 결제일', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'salesPaymentDay', label: '매출 결제일', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'creditRating', label: '신용등급', className: 'text-center w-[90px]', sortable: true },
|
||||
{ key: 'transactionGrade', label: '거래등급', className: 'text-center w-[100px]', sortable: true },
|
||||
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]', sortable: true, copyable: true },
|
||||
{ key: 'outstandingAmount', label: '미수금', className: 'text-right w-[120px]', sortable: true },
|
||||
{ key: 'badDebtStatus', label: '악성채권', className: 'text-center w-[90px]', sortable: true },
|
||||
{ key: 'status', label: '상태', className: 'text-center w-[80px]', sortable: true },
|
||||
];
|
||||
@@ -73,7 +72,7 @@ interface VendorManagementProps {
|
||||
initialTotal: number;
|
||||
}
|
||||
|
||||
export function VendorManagement({ initialData, initialTotal: _initialTotal }: VendorManagementProps) {
|
||||
export function VendorManagement({ initialData, initialTotal }: VendorManagementProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// ===== 날짜 범위 상태 =====
|
||||
@@ -130,7 +129,6 @@ export function VendorManagement({ initialData, initialTotal: _initialTotal }: V
|
||||
deleteItem: async (id: string) => {
|
||||
const result = await deleteClient(id);
|
||||
if (result.success) {
|
||||
invalidateDashboard('client');
|
||||
toast.success('거래처가 삭제되었습니다.');
|
||||
}
|
||||
return { success: result.success, error: result.error };
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
getBankAccounts,
|
||||
} from './actions';
|
||||
import { useDevFill, generateWithdrawalData } from '@/components/dev';
|
||||
import { invalidateDashboard } from '@/lib/dashboard-invalidation';
|
||||
|
||||
// ===== Props =====
|
||||
interface WithdrawalDetailClientV2Props {
|
||||
@@ -83,7 +82,6 @@ export default function WithdrawalDetailClientV2({
|
||||
: await updateWithdrawal(withdrawalId!, submitData as Partial<WithdrawalRecord>);
|
||||
|
||||
if (result.success) {
|
||||
invalidateDashboard('withdrawal');
|
||||
toast.success(mode === 'create' ? '출금 내역이 등록되었습니다.' : '출금 내역이 수정되었습니다.');
|
||||
router.push('/ko/accounting/withdrawals');
|
||||
return { success: true };
|
||||
@@ -101,7 +99,6 @@ export default function WithdrawalDetailClientV2({
|
||||
|
||||
const result = await deleteWithdrawal(withdrawalId);
|
||||
if (result.success) {
|
||||
invalidateDashboard('withdrawal');
|
||||
toast.success('출금 내역이 삭제되었습니다.');
|
||||
router.push('/ko/accounting/withdrawals');
|
||||
return { success: true };
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user