Compare commits
86 Commits
7a8d946960
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
897d44603b | ||
|
|
9b6c84c4c8 | ||
|
|
e346aa0a02 | ||
|
|
0029988e6f | ||
| 1280c8d61a | |||
| 22a398024c | |||
| 31157122ca | |||
| ac3db01859 | |||
| 156a50fd73 | |||
| b87b94860b | |||
| c210ec1b5f | |||
| 6bbc5867fe | |||
|
|
c309ac479f | ||
| 80164f722e | |||
| 742c0ba03e | |||
| 8d33fafb48 | |||
| 613d0c1069 | |||
| 13249384e2 | |||
|
|
ca5a9325c6 | ||
| 945a371cdf | |||
| f7be78b6c5 | |||
| bb1e4a25a1 | |||
| 86383719ec | |||
| b7f7aad2fd | |||
| 92b5a4a097 | |||
| 7447e8a204 | |||
| 2692865b55 | |||
| b768ac63c2 | |||
|
|
ea6ca335f1 | ||
|
|
e9ac2470e1 | ||
|
|
81affdc441 | ||
| 924726cba1 | |||
| 5b5a6bdf88 | |||
| 44a82a7ed4 | |||
| dc0c317d23 | |||
| d17b2a11a4 | |||
| 77c5bcde59 | |||
|
|
397eb2c19c | ||
| 7bd4bd38da | |||
|
|
68331be0ef | ||
|
|
7d369d1404 | ||
| 74e0e2bf44 | |||
| c94236e15c | |||
| 3bade70c5f | |||
| b7c2b99c68 | |||
| 563b240fbf | |||
| e75d8f9b25 | |||
| 4ea03922a3 | |||
| 295585d8b6 | |||
| e7263feecf | |||
| 8250eaf2b5 | |||
| 72a2a3e9a9 | |||
| 31f523c88f | |||
| a1fb0d4f9b | |||
| fe930b5831 | |||
| 899493a74d | |||
| 45ad99cb38 | |||
| 10c6e20db4 | |||
| 50e4c72c8a | |||
| eb18a3facb | |||
| 9fc979e135 | |||
| fa7efb7b24 | |||
|
|
bec933b3b4 | ||
|
|
1675f3edcf | ||
|
|
2fe47c86d3 | ||
|
|
00a6209347 | ||
| c18c68b6b7 | |||
| 03d129c32c | |||
| d6e3131c6a | |||
| 1d3805781c | |||
| b45c35a5e8 | |||
| b05e19e9f8 | |||
| 4331b84a63 | |||
| 0b81e9c1dd | |||
| f653960a30 | |||
| 888fae119f | |||
| f503e20030 | |||
| 0166601be8 | |||
| 83a23701a7 | |||
| bedfd1f559 | |||
| 8bcabafd08 | |||
| 5ff5093d7b | |||
|
|
23fa9c0ea2 | ||
|
|
cde9333652 | ||
|
|
7bb8699403 | ||
|
|
1bccaffe27 |
@@ -114,3 +114,12 @@ symbol_info_budget:
|
|||||||
# Note: the backend is fixed at startup. If a project with a different backend
|
# Note: the backend is fixed at startup. If a project with a different backend
|
||||||
# is activated post-init, an error will be returned.
|
# is activated post-init, an error will be returned.
|
||||||
language_backend:
|
language_backend:
|
||||||
|
|
||||||
|
# line ending convention to use when writing source files.
|
||||||
|
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
|
||||||
|
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
|
||||||
|
line_ending:
|
||||||
|
|
||||||
|
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||||
|
# Extends the list from the global configuration, merging the two lists.
|
||||||
|
read_only_memory_patterns: []
|
||||||
|
|||||||
102
CLAUDE.md
102
CLAUDE.md
@@ -271,6 +271,89 @@ function buildCoverageMap(items, spanKey) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 페이지 모드 라우팅 패턴 (mode=new/edit/view)
|
||||||
|
**Priority**: 🔴
|
||||||
|
|
||||||
|
### 라우팅 규칙
|
||||||
|
- **별도 `/new` 경로 금지** → `?mode=new` 쿼리파라미터 사용
|
||||||
|
- **별도 `/edit` 경로 금지** → `?mode=edit` 쿼리파라미터 사용
|
||||||
|
- 목록과 등록/수정을 **같은 page.tsx에서 분기**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 올바른 패턴: page.tsx에서 mode 분기
|
||||||
|
export default function SomePage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
|
if (mode === 'new') return <SomeForm />;
|
||||||
|
return <SomeList />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ 상세+수정: [id] 경로에서 mode 분기
|
||||||
|
export default function SomeDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||||
|
|
||||||
|
return <SomeDetail id={params.id} mode={mode} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 금지 패턴
|
||||||
|
router.push('/some-page/new') // → router.push('/some-page?mode=new')
|
||||||
|
router.push('/some-page/123/edit') // → router.push('/some-page/123?mode=edit')
|
||||||
|
```
|
||||||
|
|
||||||
|
### 등록/수정/상세 페이지 헤더
|
||||||
|
| 위치 | 요소 |
|
||||||
|
|------|------|
|
||||||
|
| 상단 좌측 | 페이지 제목 (`<h1>`) |
|
||||||
|
| 상단 우측 | `← 목록으로` 링크 (`Button variant="link"`) |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 표준 헤더
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold">페이지 제목</h1>
|
||||||
|
<Button variant="link" className="text-muted-foreground"
|
||||||
|
onClick={() => router.push(listPath)}>
|
||||||
|
← 목록으로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 하단 Sticky 액션 바 (필수)
|
||||||
|
폼 콘텐츠 아래에 **sticky bottom bar**로 버튼 배치. 취소는 좌측, 주요 액션은 우측.
|
||||||
|
|
||||||
|
| 모드 | 좌측 | 우측 |
|
||||||
|
|------|------|------|
|
||||||
|
| 등록 (new) | `X 취소` | `💾 저장` |
|
||||||
|
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
|
||||||
|
| 수정 (edit) | `X 취소` | `💾 저장` |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 표준 하단 Sticky 액션 바
|
||||||
|
<div className="sticky bottom-0 bg-white border-t shadow-sm">
|
||||||
|
<div className="px-3 py-3 md:px-6 md:py-4 flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={() => router.push(listPath)}>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : <Save className="h-4 w-4 mr-1" />}
|
||||||
|
{isNewMode ? '저장' : '저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**규칙:**
|
||||||
|
- Card 내부에 버튼 넣지 않음 → sticky 하단 바 사용
|
||||||
|
- 아이콘 포함: 취소(`X`), 저장(`Save`), 수정(`Pencil`)
|
||||||
|
- 상세(view) 모드에서 "취소"는 목록으로 이동, "수정"은 `?mode=edit` 전환
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Design Popup Policy
|
## Design Popup Policy
|
||||||
**Priority**: 🟡
|
**Priority**: 🟡
|
||||||
|
|
||||||
@@ -326,16 +409,19 @@ const [data, setData] = useState(() => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Backend API Analysis Policy
|
## Backend API Policy
|
||||||
**Priority**: 🟡
|
**Priority**: 🟡
|
||||||
|
|
||||||
- Backend API 코드는 **분석만**, 직접 수정 안 함
|
- **신규 API 생성 금지**: 새로운 엔드포인트/컨트롤러 생성은 직접 하지 않음 → 요청 문서로 정리
|
||||||
- 수정 필요 시 백엔드 요청 문서로 정리:
|
- **기존 API 수정/추가 가능**: 이미 존재하는 API의 수정, 필드 추가, 로직 변경은 직접 수행 가능
|
||||||
|
- 백엔드 경로: `sam_project/sam-api/sam-api` (PHP Laravel)
|
||||||
|
- 수정 시 기존 코드 패턴(Service-First, 기존 응답 구조) 준수
|
||||||
|
- 신규 API가 필요한 경우 요청 문서로 정리:
|
||||||
```markdown
|
```markdown
|
||||||
## 백엔드 API 수정 요청
|
## 백엔드 API 신규 요청
|
||||||
### 파일 위치: `/path/to/file.php` - 메서드명 (Line XX-XX)
|
### 엔드포인트: [HTTP METHOD /api/v1/path]
|
||||||
### 현재 문제: [설명]
|
### 목적: [설명]
|
||||||
### 수정 요청: [내용]
|
### 요청/응답 구조: [내용]
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -445,6 +531,7 @@ url: `${API_URL}/api/v1/items?${params.toString()}`
|
|||||||
|-----------|----------|
|
|-----------|----------|
|
||||||
| 검색 모달/선택 팝업 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "검색 모달" 섹션 |
|
| 검색 모달/선택 팝업 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "검색 모달" 섹션 |
|
||||||
| 리스트/목록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "리스트 페이지" 섹션 |
|
| 리스트/목록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "리스트 페이지" 섹션 |
|
||||||
|
| **IntegratedListTemplateV2 적용/리팩토링** | `claudedocs/guides/[GUIDE] common-page-patterns.md` → **"IntegratedListTemplateV2 표준 적용"** 섹션 |
|
||||||
| 상세/수정/등록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "상세/폼 페이지" 섹션 |
|
| 상세/수정/등록 페이지 | `claudedocs/guides/[GUIDE] common-page-patterns.md` → "상세/폼 페이지" 섹션 |
|
||||||
| 새 organisms 필요 | `src/components/organisms/index.ts` 먼저 확인 → 없으면 생성 |
|
| 새 organisms 필요 | `src/components/organisms/index.ts` 먼저 확인 → 없으면 생성 |
|
||||||
|
|
||||||
@@ -452,6 +539,7 @@ url: `${API_URL}/api/v1/items?${params.toString()}`
|
|||||||
- 새 파일 만들기 전 `organisms/`, `molecules/` export 목록 확인
|
- 새 파일 만들기 전 `organisms/`, `molecules/` export 목록 확인
|
||||||
- 검색+선택 모달 → `SearchableSelectionModal<T>` 사용 (직접 Dialog 조합 금지)
|
- 검색+선택 모달 → `SearchableSelectionModal<T>` 사용 (직접 Dialog 조합 금지)
|
||||||
- 리스트 페이지 → `UniversalListPage` 또는 organisms 조합
|
- 리스트 페이지 → `UniversalListPage` 또는 organisms 조합
|
||||||
|
- **IntegratedListTemplateV2 사용 시 → 컬럼 설정(`useColumnSettings` + `ColumnSettingsPopover`), 모바일 카드(`renderMobileCard`), 체크박스(`Set<string>`), 테이블 내 필터(`tableHeaderActions`) 필수 적용**
|
||||||
- 상세/폼 → Card + 기존 패턴 따르기
|
- 상세/폼 → Card + 기존 패턴 따르기
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
130
Jenkinsfile
vendored
130
Jenkinsfile
vendored
@@ -1,6 +1,12 @@
|
|||||||
pipeline {
|
pipeline {
|
||||||
agent any
|
agent any
|
||||||
|
|
||||||
|
parameters {
|
||||||
|
choice(name: 'ACTION', choices: ['deploy', 'rollback'], description: '배포 또는 롤백')
|
||||||
|
choice(name: 'ROLLBACK_TARGET', choices: ['production', 'stage'], description: '롤백 대상 환경')
|
||||||
|
string(name: 'ROLLBACK_RELEASE', defaultValue: '', description: '롤백할 릴리스 ID (예: 20260310_120000). 비워두면 직전 릴리스로 롤백')
|
||||||
|
}
|
||||||
|
|
||||||
options {
|
options {
|
||||||
disableConcurrentBuilds()
|
disableConcurrentBuilds()
|
||||||
}
|
}
|
||||||
@@ -8,21 +14,78 @@ pipeline {
|
|||||||
environment {
|
environment {
|
||||||
DEPLOY_USER = 'hskwon'
|
DEPLOY_USER = 'hskwon'
|
||||||
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
|
RELEASE_ID = new Date().format('yyyyMMdd_HHmmss')
|
||||||
|
PROD_SERVER = '211.117.60.189'
|
||||||
}
|
}
|
||||||
|
|
||||||
stages {
|
stages {
|
||||||
|
|
||||||
|
// ── 롤백: 릴리스 목록 조회 ──
|
||||||
|
stage('Rollback: List Releases') {
|
||||||
|
when { expression { params.ACTION == 'rollback' } }
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/react' : '/home/webservice/react-stage'
|
||||||
|
def pmName = params.ROLLBACK_TARGET == 'production' ? 'sam-front' : 'sam-front-stage'
|
||||||
|
sshagent(credentials: ['deploy-ssh-key']) {
|
||||||
|
def releases = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | head -6 | xargs -I{} basename {}'", returnStdout: true).trim()
|
||||||
|
def current = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'basename \$(readlink -f ${basePath}/current)'", returnStdout: true).trim()
|
||||||
|
echo "=== ${params.ROLLBACK_TARGET} 릴리스 목록 ==="
|
||||||
|
echo "현재 활성: ${current}"
|
||||||
|
echo "사용 가능:\n${releases}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 롤백: symlink 전환 ──
|
||||||
|
stage('Rollback: Switch Release') {
|
||||||
|
when { expression { params.ACTION == 'rollback' } }
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def basePath = params.ROLLBACK_TARGET == 'production' ? '/home/webservice/react' : '/home/webservice/react-stage'
|
||||||
|
def pmName = params.ROLLBACK_TARGET == 'production' ? 'sam-front' : 'sam-front-stage'
|
||||||
|
|
||||||
|
sshagent(credentials: ['deploy-ssh-key']) {
|
||||||
|
def targetRelease = params.ROLLBACK_RELEASE
|
||||||
|
if (!targetRelease?.trim()) {
|
||||||
|
targetRelease = sh(script: "ssh ${DEPLOY_USER}@${PROD_SERVER} 'ls -1dt ${basePath}/releases/*/ | sed -n 2p | xargs basename'", returnStdout: true).trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 릴리스 존재 여부 확인
|
||||||
|
sh "ssh ${DEPLOY_USER}@${PROD_SERVER} 'test -d ${basePath}/releases/${targetRelease}'"
|
||||||
|
|
||||||
|
slackSend channel: '#deploy_react', color: '#FF9800', tokenCredentialId: 'slack-token',
|
||||||
|
message: "🔄 *react* ${params.ROLLBACK_TARGET} 롤백 시작 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||||
|
|
||||||
|
sh """
|
||||||
|
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||||
|
ln -sfn ${basePath}/releases/${targetRelease} ${basePath}/current &&
|
||||||
|
cd /home/webservice && pm2 reload ${pmName}
|
||||||
|
'
|
||||||
|
"""
|
||||||
|
|
||||||
|
slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token',
|
||||||
|
message: "✅ *react* ${params.ROLLBACK_TARGET} 롤백 완료 → ${targetRelease}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 일반 배포: Checkout ──
|
||||||
stage('Checkout') {
|
stage('Checkout') {
|
||||||
|
when { expression { params.ACTION == 'deploy' } }
|
||||||
steps {
|
steps {
|
||||||
checkout scm
|
checkout scm
|
||||||
script {
|
script {
|
||||||
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
|
env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
|
||||||
}
|
}
|
||||||
slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
|
slackSend channel: '#deploy_react', color: '#439FE0', tokenCredentialId: 'slack-token',
|
||||||
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Prepare Env') {
|
stage('Prepare Env') {
|
||||||
|
when { expression { params.ACTION == 'deploy' } }
|
||||||
steps {
|
steps {
|
||||||
script {
|
script {
|
||||||
if (env.BRANCH_NAME == 'main') {
|
if (env.BRANCH_NAME == 'main') {
|
||||||
@@ -37,16 +100,23 @@ pipeline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stage('Install') {
|
stage('Install') {
|
||||||
|
when { expression { params.ACTION == 'deploy' } }
|
||||||
steps { sh 'npm install --prefer-offline' }
|
steps { sh 'npm install --prefer-offline' }
|
||||||
}
|
}
|
||||||
|
|
||||||
stage('Build') {
|
stage('Build') {
|
||||||
|
when { expression { params.ACTION == 'deploy' } }
|
||||||
steps { sh 'npm run build' }
|
steps { sh 'npm run build' }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── develop → 개발서버 배포 ──
|
// ── develop → 개발서버 배포 ──
|
||||||
stage('Deploy Development') {
|
stage('Deploy Development') {
|
||||||
when { branch 'develop' }
|
when {
|
||||||
|
allOf {
|
||||||
|
branch 'develop'
|
||||||
|
expression { params.ACTION == 'deploy' }
|
||||||
|
}
|
||||||
|
}
|
||||||
steps {
|
steps {
|
||||||
sshagent(credentials: ['deploy-ssh-key']) {
|
sshagent(credentials: ['deploy-ssh-key']) {
|
||||||
sh """
|
sh """
|
||||||
@@ -63,16 +133,21 @@ pipeline {
|
|||||||
|
|
||||||
// ── main → 운영서버 Stage 배포 ──
|
// ── main → 운영서버 Stage 배포 ──
|
||||||
stage('Deploy Stage') {
|
stage('Deploy Stage') {
|
||||||
when { branch 'main' }
|
when {
|
||||||
|
allOf {
|
||||||
|
branch 'main'
|
||||||
|
expression { params.ACTION == 'deploy' }
|
||||||
|
}
|
||||||
|
}
|
||||||
steps {
|
steps {
|
||||||
sshagent(credentials: ['deploy-ssh-key']) {
|
sshagent(credentials: ['deploy-ssh-key']) {
|
||||||
sh """
|
sh """
|
||||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
|
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
|
||||||
rsync -az --delete \
|
rsync -az --delete \
|
||||||
.next package.json next.config.ts public node_modules \
|
.next package.json next.config.ts public node_modules \
|
||||||
${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/
|
${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/
|
||||||
scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production
|
scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production
|
||||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||||
ln -sfn /home/webservice/react-stage/releases/${RELEASE_ID} /home/webservice/react-stage/current &&
|
ln -sfn /home/webservice/react-stage/releases/${RELEASE_ID} /home/webservice/react-stage/current &&
|
||||||
cd /home/webservice && pm2 reload sam-front-stage 2>/dev/null || pm2 start react-stage/current/node_modules/.bin/next --name sam-front-stage -- start -p 3100 &&
|
cd /home/webservice && pm2 reload sam-front-stage 2>/dev/null || pm2 start react-stage/current/node_modules/.bin/next --name sam-front-stage -- start -p 3100 &&
|
||||||
cd /home/webservice/react-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
|
cd /home/webservice/react-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
|
||||||
@@ -97,7 +172,12 @@ pipeline {
|
|||||||
|
|
||||||
// ── main → Production 재빌드 (운영 환경변수) ──
|
// ── main → Production 재빌드 (운영 환경변수) ──
|
||||||
stage('Rebuild for Production') {
|
stage('Rebuild for Production') {
|
||||||
when { branch 'main' }
|
when {
|
||||||
|
allOf {
|
||||||
|
branch 'main'
|
||||||
|
expression { params.ACTION == 'deploy' }
|
||||||
|
}
|
||||||
|
}
|
||||||
steps {
|
steps {
|
||||||
sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production"
|
sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production"
|
||||||
sh 'npm run build'
|
sh 'npm run build'
|
||||||
@@ -106,16 +186,21 @@ pipeline {
|
|||||||
|
|
||||||
// ── main → 운영서버 Production 배포 ──
|
// ── main → 운영서버 Production 배포 ──
|
||||||
stage('Deploy Production') {
|
stage('Deploy Production') {
|
||||||
when { branch 'main' }
|
when {
|
||||||
|
allOf {
|
||||||
|
branch 'main'
|
||||||
|
expression { params.ACTION == 'deploy' }
|
||||||
|
}
|
||||||
|
}
|
||||||
steps {
|
steps {
|
||||||
sshagent(credentials: ['deploy-ssh-key']) {
|
sshagent(credentials: ['deploy-ssh-key']) {
|
||||||
sh """
|
sh """
|
||||||
ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
|
ssh ${DEPLOY_USER}@${PROD_SERVER} 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
|
||||||
rsync -az --delete \
|
rsync -az --delete \
|
||||||
.next package.json next.config.ts public node_modules \
|
.next package.json next.config.ts public node_modules \
|
||||||
${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/
|
${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/
|
||||||
scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.production
|
scp .env.production ${DEPLOY_USER}@${PROD_SERVER}:/home/webservice/react/releases/${RELEASE_ID}/.env.production
|
||||||
ssh ${DEPLOY_USER}@211.117.60.189 '
|
ssh ${DEPLOY_USER}@${PROD_SERVER} '
|
||||||
ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
|
ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
|
||||||
cd /home/webservice && pm2 reload sam-front &&
|
cd /home/webservice && pm2 reload sam-front &&
|
||||||
cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
|
cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
|
||||||
@@ -128,12 +213,23 @@ pipeline {
|
|||||||
|
|
||||||
post {
|
post {
|
||||||
success {
|
success {
|
||||||
slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
|
script {
|
||||||
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
if (params.ACTION == 'deploy') {
|
||||||
|
slackSend channel: '#deploy_react', color: 'good', tokenCredentialId: 'slack-token',
|
||||||
|
message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
failure {
|
failure {
|
||||||
slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
|
script {
|
||||||
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
if (params.ACTION == 'deploy') {
|
||||||
|
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
|
||||||
|
message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||||
|
} else {
|
||||||
|
slackSend channel: '#deploy_react', color: 'danger', tokenCredentialId: 'slack-token',
|
||||||
|
message: "❌ *react* ${params.ROLLBACK_TARGET} 롤백 실패\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# QMS 점검표 항목 관리 기능
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
품질인정심사 시스템(QMS)의 "화면 설정" 패널에 **점검표 항목 관리** 섹션을 추가하여,
|
||||||
|
카테고리/항목의 CRUD + 순서 변경 + 버전 관리를 지원한다.
|
||||||
|
|
||||||
|
## 현재 구조
|
||||||
|
- 점검표 데이터: `MOCK_DAY1_CATEGORIES` (mockData.ts) — Mock 상태
|
||||||
|
- 타입: `ChecklistCategory` → `ChecklistSubItem[]`
|
||||||
|
- 설정 패널: `AuditSettingsPanel.tsx` — 레이아웃/점검표 옵션 토글만 존재
|
||||||
|
- 데이터 훅: `useDay1Audit.ts` — `USE_MOCK = true`
|
||||||
|
|
||||||
|
## 구현 범위
|
||||||
|
|
||||||
|
### 1. 점검표 템플릿 관리 UI (화면 설정 패널 내)
|
||||||
|
**위치**: AuditSettingsPanel → 새 섹션 "점검표 항목 관리"
|
||||||
|
|
||||||
|
**기능**:
|
||||||
|
- 현재 버전 표시 + 버전 이력 드롭다운
|
||||||
|
- 카테고리 CRUD (추가/수정/삭제)
|
||||||
|
- 하위 항목 CRUD (추가/수정/삭제)
|
||||||
|
- 순서 변경 (위/아래 버튼 — 드래그앤드롭 라이브러리 미사용)
|
||||||
|
- "저장 (새 버전 생성)" 버튼 → API 호출
|
||||||
|
- "초기화" 버튼 → 마지막 저장 상태로 복원
|
||||||
|
|
||||||
|
### 2. 데이터 구조 (프론트)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 점검표 템플릿 버전
|
||||||
|
interface ChecklistTemplateVersion {
|
||||||
|
id: string;
|
||||||
|
version: number;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
description?: string; // 변경 사유
|
||||||
|
}
|
||||||
|
|
||||||
|
// 점검표 템플릿 (API 응답)
|
||||||
|
interface ChecklistTemplate {
|
||||||
|
id: string;
|
||||||
|
currentVersion: number;
|
||||||
|
categories: ChecklistCategory[]; // 기존 타입 재사용
|
||||||
|
versions: ChecklistTemplateVersion[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. API 엔드포인트 (Mock → 추후 연동)
|
||||||
|
|
||||||
|
| Method | Endpoint | 설명 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| GET | `/api/v1/qms/checklist-templates/current` | 현재 템플릿 조회 |
|
||||||
|
| POST | `/api/v1/qms/checklist-templates` | 새 버전 저장 |
|
||||||
|
| GET | `/api/v1/qms/checklist-templates/versions` | 버전 이력 조회 |
|
||||||
|
| GET | `/api/v1/qms/checklist-templates/versions/:id` | 특정 버전 조회 |
|
||||||
|
| POST | `/api/v1/qms/checklist-templates/versions/:id/restore` | 버전 복원 |
|
||||||
|
|
||||||
|
### 4. UI 구성 (설정 패널 내)
|
||||||
|
|
||||||
|
```
|
||||||
|
━━ 점검표 항목 관리 ━━
|
||||||
|
|
||||||
|
[v3 (2026-03-10) ▾] ← 버전 셀렉트 (이력 조회/복원)
|
||||||
|
|
||||||
|
── 카테고리 ──
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ [⬆][⬇] 1. 원재료 품질관리 기준 [✏️][🗑] │
|
||||||
|
│ [⬆][⬇] 수입검사 기준 확인 [✏️][🗑] │
|
||||||
|
│ [⬆][⬇] 불합격품 처리 기준 확인 [✏️][🗑] │
|
||||||
|
│ [⬆][⬇] 자재 보관 기준 확인 [✏️][🗑] │
|
||||||
|
│ [+ 항목 추가] │
|
||||||
|
├─────────────────────────────────────┤
|
||||||
|
│ [⬆][⬇] 2. 제조공정 관리 기준 [✏️][🗑] │
|
||||||
|
│ ... │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
[+ 카테고리 추가]
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
[초기화] [저장 (새 버전)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 작업 목록
|
||||||
|
|
||||||
|
- [ ] types.ts에 템플릿 관련 타입 추가
|
||||||
|
- [ ] ChecklistTemplateEditor 컴포넌트 생성 (편집 UI)
|
||||||
|
- [ ] AuditSettingsPanel에 탭/섹션 추가 ("화면 설정" / "점검표 관리")
|
||||||
|
- [ ] useChecklistTemplate 훅 생성 (상태 관리 + Mock 데이터)
|
||||||
|
- [ ] page.tsx 연동 (훅 → 설정 패널 props)
|
||||||
|
- [ ] 버전 이력 UI (Select 드롭다운 + 복원 확인)
|
||||||
|
|
||||||
|
### 6. 설계 결정
|
||||||
|
|
||||||
|
- **드래그앤드롭 미사용**: 패키지 추가 없이 ⬆⬇ 버튼으로 순서 변경
|
||||||
|
- **설정 패널 분리**: 기존 "화면 설정"과 "점검표 관리"를 탭으로 분리
|
||||||
|
- **Mock 우선**: `USE_MOCK = true`로 시작, API 연동 시 교체
|
||||||
|
- **인라인 편집**: 항목명 클릭 시 input으로 전환 (별도 모달 없음)
|
||||||
|
- **낙관적 업데이트**: 로컬 편집 → 저장 버튼 클릭 시 한번에 API 호출
|
||||||
54
claudedocs/[FIX-2026-03-10] eslint-cleanup-checklist.md
Normal file
54
claudedocs/[FIX-2026-03-10] eslint-cleanup-checklist.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# ESLint 코드 정리 체크리스트
|
||||||
|
|
||||||
|
## 점검 결과 요약
|
||||||
|
- **TypeScript**: 0건 (완벽)
|
||||||
|
- **ESLint**: 923 errors + 220 warnings (1,529개 파일 중 399개)
|
||||||
|
|
||||||
|
## 수정 대상 (exhaustive-deps 제외 - 동작 변경 위험)
|
||||||
|
|
||||||
|
### ✅ 완료
|
||||||
|
|
||||||
|
| 룰 | 건수 | 상태 | 수정 내용 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `no-unreachable` | 7 | ✅ 완료 | 도달 불가 catch 블록 제거 (construction actions 3파일) |
|
||||||
|
| `no-constant-binary-expression` | 6 | ✅ 완료 | `false && ...` 조건 제거 (MasterFieldTab, SectionsTab) |
|
||||||
|
| `no-useless-escape` | 6 | ✅ 완료 | 불필요한 `\` 제거 (CurrencyField, currency-input, number-input, locale.ts) |
|
||||||
|
| `no-case-declarations` | 21 | ✅ 완료 | switch case에 `{}` 블록 추가 (5파일) |
|
||||||
|
|
||||||
|
### ⏳ 미완료
|
||||||
|
|
||||||
|
| 룰 | 건수 | 상태 | 수정 방법 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `no-unused-vars` | 707 | ⏳ 대기 | `eslint-plugin-unused-imports` 자동 수정 예정 |
|
||||||
|
|
||||||
|
## unused-vars 수정 계획
|
||||||
|
|
||||||
|
### 준비 상태
|
||||||
|
- `eslint-plugin-unused-imports` 이미 설치됨 (npm install -D 완료)
|
||||||
|
- eslint.config.mjs 아직 미수정
|
||||||
|
|
||||||
|
### 실행 순서
|
||||||
|
```bash
|
||||||
|
# 1. eslint.config.mjs에 플러그인 임시 추가
|
||||||
|
# 2. npx eslint --fix src/ (unused-imports 룰만)
|
||||||
|
# 3. eslint.config.mjs 원복
|
||||||
|
# 4. npx eslint src/ 로 결과 확인
|
||||||
|
# 5. eslint-plugin-unused-imports 패키지 제거
|
||||||
|
```
|
||||||
|
|
||||||
|
### unused-vars 파일 분포 (284개 파일)
|
||||||
|
- src/app/: 44파일
|
||||||
|
- src/components/business/: 33파일
|
||||||
|
- src/components/accounting+hr/: 42파일
|
||||||
|
- src/components/items+orders+quotes+production/: 55파일
|
||||||
|
- src/components/ 기타: 95파일
|
||||||
|
- src/lib+stores+types/: 15파일
|
||||||
|
|
||||||
|
## 수정하지 않는 항목
|
||||||
|
|
||||||
|
| 룰 | 건수 | 사유 |
|
||||||
|
|---|---|---|
|
||||||
|
| `no-explicit-any` | 155 | warning 수준, 타입 정의 필요 (별도 작업) |
|
||||||
|
| `exhaustive-deps` | 24 | useEffect 재실행 빈도 변경 위험 |
|
||||||
|
| `no-img-element` | 39 | next/image 전환은 별도 작업 |
|
||||||
|
| `no-undef` | 168 | globals 설정 추가 필요 (sessionStorage 등) |
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# 계정과목 통합 프로젝트 체크리스트
|
||||||
|
|
||||||
|
> 시작: 2026-03-06
|
||||||
|
> 목표: 계정과목 마스터 통합 → 분개 흐름 통합 → 대시보드 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: 계정과목 마스터 강화 (백엔드)
|
||||||
|
|
||||||
|
### 1-1. account_codes 테이블 확장
|
||||||
|
- [x] 마이그레이션: sub_category(중분류), depth(계층), parent_code(상위계정), department_type(부문) 추가
|
||||||
|
- [x] AccountCode 모델 업데이트 (fillable, casts, 관계)
|
||||||
|
- [x] AccountCodeService 확장 (계층 조회, 부문 필터 지원)
|
||||||
|
- [x] AccountSubjectController 확장 (새 필드 지원 API)
|
||||||
|
- [x] UpdateAccountSubjectRequest 생성
|
||||||
|
- [x] 라우트 추가 (PUT /{id}, POST /seed-defaults)
|
||||||
|
|
||||||
|
### 1-2. 표준 계정과목표 시드 데이터 (더존 Smart A 기준)
|
||||||
|
- [x] 시드 데이터 정의 (대분류 5개 + 중분류 12개 + 소분류 111개 = 128건)
|
||||||
|
- [x] seedDefaults() API 엔드포인트 (별도 Seeder 대신 API로 제공)
|
||||||
|
- [x] 기존 데이터와 충돌 방지 로직 (tenant_id+code 중복 시 skip)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 프론트 공용 컴포넌트
|
||||||
|
|
||||||
|
### 2-1. 공용 계정과목 설정 모달 (리스트 페이지용 - CRUD)
|
||||||
|
- [x] AccountSubjectSettingModal 공용 컴포넌트 생성 (src/components/accounting/common/)
|
||||||
|
- [x] 기존 GeneralJournalEntry/AccountSubjectSettingModal 코드 이관 + 확장
|
||||||
|
- [x] 계층 표시 (depth별 들여쓰기: 대→중→소)
|
||||||
|
- [x] 부문 컬럼 추가
|
||||||
|
- [x] "기본 계정과목 생성" 버튼 (seedDefaults API 연동)
|
||||||
|
|
||||||
|
### 2-2. 공용 계정과목 Select (세부 페이지/모달용 - 조회/선택)
|
||||||
|
- [x] AccountSubjectSelect 공용 컴포넌트 생성
|
||||||
|
- [x] DB 마스터 API 호출로 옵션 로드 (selectable=true, isActive=true)
|
||||||
|
- [x] 활성 계정과목만 표시
|
||||||
|
- [x] "[코드] 계정과목명" 형태 표시 (예: [51100] 복리후생비(제조))
|
||||||
|
- [x] 분류별 필터 지원 (props: category, subCategory, departmentType)
|
||||||
|
|
||||||
|
### 2-3. 공용 타입/API 함수
|
||||||
|
- [x] 공용 타입 정의 (src/components/accounting/common/types.ts)
|
||||||
|
- [x] 공용 actions.ts (계정과목 CRUD + seedDefaults + update API)
|
||||||
|
- [x] index.ts 배럴 파일 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: 7개 모듈 전환 (프론트)
|
||||||
|
|
||||||
|
### 3-1. 일반전표입력
|
||||||
|
- [x] 전용 AccountSubjectSettingModal → 공용 컴포넌트로 교체
|
||||||
|
- [x] 전용 타입/API → 공용으로 교체 (actions.ts, types.ts 정리)
|
||||||
|
- [x] ManualJournalEntryModal: getAccountSubjects → 공용 actions
|
||||||
|
- [x] JournalEditModal: getAccountSubjects → 공용 actions
|
||||||
|
- [x] 전용 AccountSubjectSettingModal.tsx 삭제
|
||||||
|
|
||||||
|
### 3-2. 세금계산서관리
|
||||||
|
- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
|
||||||
|
|
||||||
|
### 3-3. 카드사용내역
|
||||||
|
- [x] JournalEntryModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
|
||||||
|
- [x] ManualInputModal: ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect
|
||||||
|
- [x] index.tsx 인라인 Select → AccountSubjectSelect
|
||||||
|
- 참고: ACCOUNT_SUBJECT_OPTIONS 상수는 엑셀 변환에서 기존 데이터 호환용으로 유지
|
||||||
|
|
||||||
|
### 3-4. 입금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님)
|
||||||
|
### 3-5. 출금관리 — 스킵 (거래유형 분류이며 계정과목 코드가 아님)
|
||||||
|
|
||||||
|
### 3-6. 미지급비용
|
||||||
|
- [x] ACCOUNT_SUBJECT_OPTIONS → AccountSubjectSelect (category="expense" 필터)
|
||||||
|
|
||||||
|
### 3-7. 매출관리 — 보류 (매출유형 분류이며 계정과목 코드가 아님)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: 분개 흐름 통합 (백엔드)
|
||||||
|
|
||||||
|
### 4-1. source_type 확장
|
||||||
|
- [x] JournalEntry 모델에 SOURCE_CARD_TRANSACTION, SOURCE_TAX_INVOICE 상수 추가
|
||||||
|
- [x] source_type은 string(30)이므로 enum 마이그레이션 불필요 (상수 추가만으로 완료)
|
||||||
|
|
||||||
|
### 4-2. 세금계산서 분개 통합
|
||||||
|
- [x] JournalSyncService 생성 (공용 분개 CRUD + expense 동기화)
|
||||||
|
- [x] TaxInvoiceController에 journal CRUD 메서드 추가 (get/store/delete)
|
||||||
|
- [x] 라우트 추가: GET/POST/PUT/DELETE /api/v1/tax-invoices/{id}/journal-entries
|
||||||
|
- [x] source_type = 'tax_invoice', source_key = 'tax_invoice_{id}'
|
||||||
|
|
||||||
|
### 4-3. 카드사용내역 분개 통합
|
||||||
|
- [x] CardTransactionController에 journal CRUD 메서드 추가 (get/store)
|
||||||
|
- [x] 라우트 추가: GET/POST /api/v1/card-transactions/{id}/journal-entries
|
||||||
|
- [x] 카드 items → 차변(비용계정) + 대변(미지급금) 자동 변환
|
||||||
|
- [x] source_type = 'card_transaction', source_key = 'card_{id}'
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: 대시보드 연동
|
||||||
|
|
||||||
|
### 5-1. expense_accounts 동기화 확장
|
||||||
|
- [x] SyncsExpenseAccounts 트레이트 생성 (app/Traits/)
|
||||||
|
- [x] GeneralJournalEntryService → 트레이트 사용으로 전환
|
||||||
|
- [x] JournalSyncService에서 트레이트 사용 (세금계산서/카드 분개 저장 시 자동 동기화)
|
||||||
|
- [x] source_type별 payment_method 자동 결정 (card_transaction → PAYMENT_CARD)
|
||||||
|
- [x] 모든 source_type에서 복리후생비/접대비 감지
|
||||||
|
|
||||||
|
### 5-2. 대시보드 집계 검증
|
||||||
|
- [x] expense_accounts에 journal_entry_id/journal_entry_line_id 연결 (기존 마이그레이션 활용)
|
||||||
|
- [x] CEO 대시보드는 expense_accounts 테이블 기준 집계 → 모든 source_type 반영됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 작업 순서 및 의존성
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 (백엔드 마스터 강화)
|
||||||
|
↓
|
||||||
|
Phase 2 (프론트 공용 컴포넌트)
|
||||||
|
↓
|
||||||
|
Phase 3 (7개 모듈 전환) — 모듈별 독립, 병렬 가능
|
||||||
|
↓
|
||||||
|
Phase 4 (분개 흐름 통합) — Phase 3과 병렬 가능
|
||||||
|
↓
|
||||||
|
Phase 5 (대시보드 연동)
|
||||||
|
```
|
||||||
250
claudedocs/[IMPL-2026-03-08] frontend-weekly-0302-0308.md
Normal file
250
claudedocs/[IMPL-2026-03-08] frontend-weekly-0302-0308.md
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
# 프론트엔드 주간 구현내역 (2026-03-02 ~ 2026-03-08)
|
||||||
|
|
||||||
|
> 총 커밋 59개 (feat 30 / fix 17 / refactor 3 / chore 3 / merge 1 / 기타 5)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 품질관리 — Mock→API 전환 + 검사 모달/문서 대폭 개선
|
||||||
|
|
||||||
|
**커밋**: 50e4c72c, 563b240f, 899493a7, fe930b58, e7263fee, 4ea03922, 295585d8, c150d807, e75d8f9b (9개)
|
||||||
|
**변경 규모**: +2,210 / -566 라인
|
||||||
|
|
||||||
|
### 1-1. API 전환
|
||||||
|
- `USE_MOCK_FALLBACK = false` 설정, Mock 데이터 제거
|
||||||
|
- 엔드포인트: `/api/v1/quality/documents`, `/api/v1/quality/performance-reports`
|
||||||
|
- snake_case → camelCase 변환 함수 구현
|
||||||
|
- InspectionFormData에 `clientId`, `inspectorId`, `receptionDate` 필드 추가
|
||||||
|
|
||||||
|
### 1-2. 검사 모달 개선 (InspectionInputModal)
|
||||||
|
- 일괄 합격/초기화 토글 버튼 추가
|
||||||
|
- 시공 치수 필드 (너비/높이) 추가
|
||||||
|
- 변경사유 입력 필드 추가
|
||||||
|
- 사진 첨부 (최대 2장, base64)
|
||||||
|
- 이전/다음 개소 네비게이션 + 자동저장
|
||||||
|
- 레거시 검사 데이터 통합 (합격/불합격/진행중/미완)
|
||||||
|
|
||||||
|
### 1-3. 수주선택 모달 (OrderSelectModal)
|
||||||
|
- 발주처(clientName) 컬럼 추가
|
||||||
|
- 동일 발주처 + 동일 모델 필터링 제약
|
||||||
|
- `SearchableSelectionModal`에 `isItemDisabled` 콜백 추가 (공통 컴포넌트 확장)
|
||||||
|
- 비활성 항목 스타일링 + 전체선택 시 비활성 항목 제외
|
||||||
|
|
||||||
|
### 1-4. 제품검사 성적서 (FqcDocumentContent)
|
||||||
|
- 8컬럼 동적 렌더링: No / 검사항목 / 세부항목 / 검사기준 / 검사방법 / 검사주기 / 측정값 / 판정
|
||||||
|
- rowSpan 병합: 카테고리 단일 + method+frequency 복합 병합
|
||||||
|
- measurement_type별 처리: checkbox → 양호/불량, numeric → 숫자입력, none → 비활성
|
||||||
|
- FQC 모드 우선 + legacy fallback 패턴
|
||||||
|
|
||||||
|
### 1-5. 제품검사 요청서 (FqcRequestDocumentContent) — 신규
|
||||||
|
- 양식 기반 동적 렌더링 (template_id: 66)
|
||||||
|
- 결재라인 + 기본정보(7개) + 입력섹션(4개) + 사전통보 테이블
|
||||||
|
- EAV 데이터 구조: section_id, column_id, row_index, field_key, field_value
|
||||||
|
- EAV 문서 없을 때 legacy fallback 적용
|
||||||
|
|
||||||
|
### 1-6. 수주 연결 동기화
|
||||||
|
- order_ids 배열 매핑 (다중 수주 지원)
|
||||||
|
- 개소별 inspectionData 서버 저장
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/quality/InspectionManagement/actions.ts`
|
||||||
|
- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx`
|
||||||
|
- `src/components/quality/InspectionManagement/OrderSelectModal.tsx`
|
||||||
|
- `src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx` (신규)
|
||||||
|
- `src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx` (신규)
|
||||||
|
- `src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 문서스냅샷 시스템 (Lazy Snapshot) — 신규 기능
|
||||||
|
|
||||||
|
**커밋**: 31f523c8, a1fb0d4f, 8250eaf2, 72a2a3e9, 04f2a8a7 (5개)
|
||||||
|
**변경 규모**: +300 라인
|
||||||
|
|
||||||
|
### 개요
|
||||||
|
문서 저장/조회 시 `rendered_html` 스냅샷을 자동 캡처하여 백엔드에 전송하는 시스템.
|
||||||
|
|
||||||
|
### 2-1. 수동 캡처 (저장 시)
|
||||||
|
- 검사성적서(InspectionReportModal): `contentWrapperRef.innerHTML` 캡처 → 저장 시 `rendered_html` 파라미터 포함
|
||||||
|
- 작업일지(WorkLogModal): 동일 패턴
|
||||||
|
- 수입검사(ImportInspectionInputModal): 오프스크린 렌더링 방식
|
||||||
|
|
||||||
|
### 2-2. Lazy Snapshot (조회 시 자동 캡처)
|
||||||
|
- 조건: `rendered_html === NULL`인 문서 조회 시
|
||||||
|
- 동작: 500ms 지연 → innerHTML 캡처 → 백그라운드 PATCH
|
||||||
|
- 비차단(non-blocking): UI에 영향 없이 백그라운드 처리
|
||||||
|
- `patchDocumentSnapshot()` 서버 액션으로 전송
|
||||||
|
|
||||||
|
### 2-3. 오프스크린 렌더링 유틸리티
|
||||||
|
- `src/lib/utils/capture-rendered-html.tsx` (신규)
|
||||||
|
- 폼 HTML이 아닌 실제 문서 렌더링 결과를 캡처
|
||||||
|
- readOnly 모드 자동 캡처 useEffect 제거 (불필요한 PUT 요청 방지)
|
||||||
|
|
||||||
|
### 적용 범위
|
||||||
|
| 문서 | 수동 캡처 | Lazy Snapshot |
|
||||||
|
|------|-----------|---------------|
|
||||||
|
| 검사성적서 | ✅ | ✅ |
|
||||||
|
| 작업일지 | ✅ | ✅ |
|
||||||
|
| 수입검사 | ✅ (오프스크린) | - |
|
||||||
|
| 제품검사 요청서 | ✅ | ✅ |
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/lib/utils/capture-rendered-html.tsx` (신규)
|
||||||
|
- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx`
|
||||||
|
- `src/components/production/WorkerScreen/WorkLogModal.tsx`
|
||||||
|
- `src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx`
|
||||||
|
- `src/components/production/WorkOrders/actions.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 생산지시 — API 연동 + 작업자 화면 + 중간검사
|
||||||
|
|
||||||
|
**커밋**: fa7efb7b, 8b6da749, 4331b84a, 0166601b, 8bcabafd, 2a2a356f, b45c35a5, 0b81e9c1 (8개)
|
||||||
|
**변경 규모**: +2,000 라인
|
||||||
|
|
||||||
|
### 3-1. 생산지시 목록/상세 API 연동
|
||||||
|
- Mock → API 전환 (`executePaginatedAction` + `buildApiUrl` 패턴)
|
||||||
|
- 서버사이드 페이지네이션 + 동적 탭 카운트 (stats API)
|
||||||
|
- WorkOrder 상태 배지 6단계: 미배정→배정→작업중→검사→완료→출하
|
||||||
|
- BOM null 상태 처리
|
||||||
|
|
||||||
|
### 3-2. 절곡 중간검사 입력 모달 (InspectionInputModal)
|
||||||
|
- 7개 제품 항목 통합 폼
|
||||||
|
- 제품 ID 자동 매칭: 정규화 → 키워드 → 인덱스 fallback (3단계)
|
||||||
|
- cellValues 구조: `{bending_state, length, width, spacing}`
|
||||||
|
- PO 단위 데이터 공유 모델: 동일 PO 내 모든 아이템이 inspection_data 공유
|
||||||
|
|
||||||
|
### 3-3. 자재투입 모달 (MaterialInputModal)
|
||||||
|
- 동일 자재 다중 BOM 그룹 LOT 독립 관리
|
||||||
|
- `bomGroupKey = ${item_id}-${category}-${partType}` 그룹핑
|
||||||
|
- 카테고리 정렬: 가이드레일(1) → 하단마감재(2) → 셔터박스(3) → 연기차단재(4)
|
||||||
|
- FIFO 자동충전 시 그룹 간 물리적 LOT 가용량 추적
|
||||||
|
- 번호 배지(①②③) + partType 배지
|
||||||
|
|
||||||
|
### 3-4. 공정 단계 검사범위 설정 (InspectionScope) — 신규
|
||||||
|
- 전수검사 / 샘플링 / 그룹 3가지 타입
|
||||||
|
- 샘플링 시 샘플 수(n) 입력 지원
|
||||||
|
- StepForm 컴포넌트에 UI 추가, options JSON으로 API 저장
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/production/ProductionOrders/actions.ts`, `types.ts`
|
||||||
|
- `src/components/production/WorkerScreen/InspectionInputModal.tsx`
|
||||||
|
- `src/components/production/WorkerScreen/MaterialInputModal.tsx`
|
||||||
|
- `src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx` (신규)
|
||||||
|
- `src/components/process-management/StepForm.tsx`
|
||||||
|
- `src/types/process.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 출하/배차 — 배차 다중행 + 차량관리 API + 출고관리
|
||||||
|
|
||||||
|
**커밋**: 83a23701, 1d380578, 5ff5093d, f653960a, a4f99ae3, 03d129c3 (6개)
|
||||||
|
**변경 규모**: +2,400 / -1,100 라인
|
||||||
|
|
||||||
|
### 4-1. 배차정보 다중 행 API 연동
|
||||||
|
- `vehicle_dispatches` 배열 지원 (기존 단일 배차 → 다중 배차)
|
||||||
|
- transform 함수: `transformApiToDetail`, `transformCreateFormToApi`, `transformEditFormToApi` 갱신
|
||||||
|
- 레거시 단일 배차 필드 하위호환 유지
|
||||||
|
|
||||||
|
### 4-2. 배차차량관리 Mock→API 전환
|
||||||
|
- `executePaginatedAction` + `buildApiUrl` 패턴 적용
|
||||||
|
- `transformToListItem()` / `transformToDetail()` snake_case → camelCase 변환
|
||||||
|
- 쿼리 파라미터: `search`, `status`, `start_date`, `end_date`, `page`, `per_page`
|
||||||
|
|
||||||
|
### 4-3. 출고관리 목록 필드 매핑
|
||||||
|
- `writer_name`, `writer_id`, `delivery_date` 등 5개 필드 API 매핑 추가
|
||||||
|
- `OrderInfoApiData` 타입으로 주문 연결 정보 처리
|
||||||
|
|
||||||
|
### 4-4. 배차 상세/수정 레이아웃 개선
|
||||||
|
- 기본정보 그리드: 1열 → 2×4열 레이아웃
|
||||||
|
|
||||||
|
### 4-5. 출하관리 캘린더
|
||||||
|
- 기본 뷰: day → week-time 변경
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/outbound/ShipmentManagement/actions.ts`
|
||||||
|
- `src/components/outbound/VehicleDispatchManagement/actions.ts`
|
||||||
|
- `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx`, `ShipmentEdit.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 전자결재 — 결재함 확장 + 연결문서
|
||||||
|
|
||||||
|
**커밋**: 181352d7, 72cf5d86 (2개)
|
||||||
|
**변경 규모**: +458 / -127 라인
|
||||||
|
|
||||||
|
### 5-1. 결재함 기능 확장
|
||||||
|
- 결재함 API 연동:
|
||||||
|
- `GET /api/v1/approvals/inbox` — 결재함 목록
|
||||||
|
- `GET /api/v1/approvals/inbox/summary` — 통계
|
||||||
|
- `POST /api/v1/approvals/{id}/approve` — 승인
|
||||||
|
- `POST /api/v1/approvals/{id}/reject` — 반려
|
||||||
|
- 문서 상태: DRAFT → PENDING → APPROVED / REJECTED / CANCELLED
|
||||||
|
|
||||||
|
### 5-2. 연결문서 기능 (LinkedDocumentContent) — 신규
|
||||||
|
- 검사성적서, 작업일지 등을 결재 문서에 연결하여 렌더링
|
||||||
|
- DocumentHeader 컴포넌트 활용, 결재라인/상태배지/메타 정보 표시
|
||||||
|
|
||||||
|
### 5-3. 모바일 반응형
|
||||||
|
- AuthenticatedLayout: 사이드바/메인 콘텐츠 모바일 대응
|
||||||
|
- HeaderFavoritesBar 전면 재설계
|
||||||
|
- SearchableSelectionModal HTML 유효성 수정
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/approval/ApprovalBox/actions.ts`, `index.tsx`, `types.ts`
|
||||||
|
- `src/components/approval/DocumentDetail/LinkedDocumentContent.tsx` (신규)
|
||||||
|
- `src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx`
|
||||||
|
- `src/layouts/AuthenticatedLayout.tsx`
|
||||||
|
- `src/components/layout/HeaderFavoritesBar.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. CEO 대시보드 — API 연동 + 섹션 확장 + 리팩토링
|
||||||
|
|
||||||
|
**커밋**: 9ad4c8ee, 23fa9c0e, cde93336, 4e179d2e, db84d679, 1bccaffe, bec933b3, 1675f3ed (8개)
|
||||||
|
**별도 문서**: `claudedocs/dashboard/[VERIFY-2026-03-06] ceo-dashboard-data-flow-verification.md`
|
||||||
|
|
||||||
|
### 주요 변경
|
||||||
|
- SummaryNavBar 추가 (상단 요약 데이터 네비게이션)
|
||||||
|
- 접대비/복리후생비/매출채권/캘린더 섹션 개선
|
||||||
|
- 컴포넌트 분리 및 모달/섹션 리팩토링
|
||||||
|
- mockData/modalConfigs 정리
|
||||||
|
- API 연동 강화 (회계/결재/HR 섹션)
|
||||||
|
- `invalidateDashboard()` 시스템 추가 (5개 도메인 연동)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 회계 — 계정과목 공통화 + 어음 리팩토링
|
||||||
|
|
||||||
|
**커밋**: 7d369d14, 1691337f, a4f99ae3(일부) (3개)
|
||||||
|
**별도 문서**: `claudedocs/[IMPL-2026-03-06] account-subject-unification-checklist.md`
|
||||||
|
|
||||||
|
### 주요 변경
|
||||||
|
- AccountSubjectSelect 공통 컴포넌트: 7개 페이지에 일괄 적용
|
||||||
|
- 매출/매입/부실채권/일일보고 UI 개선
|
||||||
|
- BillManagement 섹션 분리: 11개 섹션 컴포넌트 + 커스텀 훅(`useBillForm`, `useBillConditions`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 기타
|
||||||
|
|
||||||
|
### E2E 테스트
|
||||||
|
- `f5bdc5ba`: 11개 FAIL 시나리오 수정 후 전체 PASS
|
||||||
|
|
||||||
|
### 인프라
|
||||||
|
- `f9eea0c9`, `c18c68b6`: Slack 알림 채널 분리 (product_infra → deploy_react)
|
||||||
|
- `888fae11`: next dev에서 --turbo 플래그 제거
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 문서 현황
|
||||||
|
|
||||||
|
| 도메인 | 문서 상태 |
|
||||||
|
|--------|----------|
|
||||||
|
| 품질관리 Mock→API | ✅ 본 문서 §1 |
|
||||||
|
| 문서스냅샷 (Lazy Snapshot) | ✅ 본 문서 §2 |
|
||||||
|
| 생산지시 API 연동 | ✅ 본 문서 §3 |
|
||||||
|
| 출하/배차 API 연동 | ✅ 본 문서 §4 |
|
||||||
|
| 전자결재 확장 | ✅ 본 문서 §5 |
|
||||||
|
| CEO 대시보드 | ✅ 별도 문서 존재 |
|
||||||
|
| 계정과목 공통화 | ✅ 별도 문서 존재 |
|
||||||
|
| 백엔드 구현내역 | ✅ 일별 문서 존재 (03-02 ~ 03-08) |
|
||||||
103
claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md
Normal file
103
claudedocs/[IMPL-2026-03-10] notice-popup-display-integration.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# [IMPL] 공지 팝업 사용자 표시 연동
|
||||||
|
|
||||||
|
> 관리자가 등록한 팝업을 사용자에게 자동 표시하는 기능 구현
|
||||||
|
|
||||||
|
## 현황
|
||||||
|
|
||||||
|
| 구분 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| 관리자 팝업 관리 UI (CRUD) | ✅ 완성 |
|
||||||
|
| 백엔드 API (`/api/v1/popups`) | ✅ 완성 |
|
||||||
|
| `NoticePopupModal` 표시 컴포넌트 | ✅ 완성 |
|
||||||
|
| 활성 팝업 조회 서버 액션 | ✅ 완성 |
|
||||||
|
| 레이아웃 자동 표시 연동 | ✅ 완성 |
|
||||||
|
| 부서별 팝업 필터링 (백엔드) | ✅ 완성 (2026-03-10) |
|
||||||
|
| 부서별 팝업 필터링 (프론트) | ✅ 완성 (2026-03-10) |
|
||||||
|
| 부서 선택 UI (관리자 폼) | ✅ 완성 (2026-03-10) |
|
||||||
|
|
||||||
|
## 구현 범위 (프론트만)
|
||||||
|
|
||||||
|
### 1. `getActivePopups()` 서버 액션
|
||||||
|
- 위치: `src/components/common/NoticePopupModal/actions.ts`
|
||||||
|
- `GET /api/v1/popups?status=active` 호출
|
||||||
|
- 기존 `PopupApiData` → `NoticePopupData` 변환
|
||||||
|
|
||||||
|
### 2. `NoticePopupContainer` 컴포넌트
|
||||||
|
- 위치: `src/components/common/NoticePopupModal/NoticePopupContainer.tsx`
|
||||||
|
- 로그인 후 활성 팝업 fetch
|
||||||
|
- `isPopupDismissedForToday()` 필터링
|
||||||
|
- 여러 개 팝업 순차 표시 (하나 닫으면 다음 팝업)
|
||||||
|
|
||||||
|
### 3. `AuthenticatedLayout` 연동
|
||||||
|
- `NoticePopupContainer` 렌더링 추가
|
||||||
|
|
||||||
|
## 기존 파일 활용
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/common/NoticePopupModal/
|
||||||
|
├── NoticePopupModal.tsx ← 기존 (수정 없음)
|
||||||
|
├── NoticePopupContainer.tsx ← 신규
|
||||||
|
└── actions.ts ← 신규
|
||||||
|
|
||||||
|
src/components/settings/PopupManagement/
|
||||||
|
├── utils.ts ← transformApiToFrontend 재사용
|
||||||
|
└── types.ts ← PopupApiData 타입 재사용
|
||||||
|
|
||||||
|
src/layouts/AuthenticatedLayout.tsx ← NoticePopupContainer 추가
|
||||||
|
```
|
||||||
|
|
||||||
|
## 동작 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
로그인 → AuthenticatedLayout 마운트
|
||||||
|
→ NoticePopupContainer useEffect
|
||||||
|
→ localStorage에서 user.department_id 조회
|
||||||
|
→ getActivePopups(departmentId) API 호출
|
||||||
|
→ 백엔드 scopeForUser(departmentId) 적용
|
||||||
|
→ target_type='all' 팝업 + 해당 부서 팝업 반환
|
||||||
|
→ 날짜 범위(startDate~endDate) 필터
|
||||||
|
→ isPopupDismissedForToday() 필터
|
||||||
|
→ 표시할 팝업 있으면 첫 번째 팝업 모달 표시
|
||||||
|
→ 닫기 클릭 → "오늘 하루 안 보기" 체크 시 localStorage 저장
|
||||||
|
→ 다음 팝업 표시 (없으면 종료)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2026-03-10] 부서별 팝업 필터링 + 부서 선택 UI
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
팝업 대상이 "부서별"일 때 어떤 부서인지 선택할 수 없었고, 사용자에게도 부서 기반 필터링이 적용되지 않았음.
|
||||||
|
|
||||||
|
### 변경사항
|
||||||
|
|
||||||
|
#### 백엔드 (sam-api)
|
||||||
|
- `MemberService::getUserInfoForLogin()` — 로그인 응답에 `department_id` 추가
|
||||||
|
- `PopupService` — `scopeForUser(?int $departmentId)` 스코프로 부서별 필터링
|
||||||
|
|
||||||
|
#### 프론트엔드
|
||||||
|
| 파일 | 변경 |
|
||||||
|
|------|------|
|
||||||
|
| `LoginPage.tsx` | localStorage user에 `department_id` 저장 |
|
||||||
|
| `NoticePopupContainer.tsx` | `user.department_id`를 `getActivePopups()`에 전달 |
|
||||||
|
| `popupDetailConfig.ts` | `target` 필드를 custom 렌더로 변경, `TargetSelectorField` 컴포넌트 추가 |
|
||||||
|
| `PopupDetailClientV2.tsx` | `handleSubmit`에서 `decodeTargetValue()`로 `targetDepartmentId` 추출 |
|
||||||
|
| `types.ts` | `Popup.targetId`, `Popup.targetName` 필드 추가 |
|
||||||
|
| `utils.ts` | `transformApiToFrontend`에 `targetId`, `targetName` 매핑 추가 |
|
||||||
|
| `actions.ts` | `getDepartmentList()` 서버 액션 추가 |
|
||||||
|
|
||||||
|
### 핵심 구현: 대상 필드 값 인코딩
|
||||||
|
```typescript
|
||||||
|
// 단일 form field에 target_type + department_id를 함께 저장
|
||||||
|
encodeTargetValue('department', 13) → 'department:13'
|
||||||
|
decodeTargetValue('department:13') → { targetType: 'department', departmentId: 13 }
|
||||||
|
encodeTargetValue('all') → 'all'
|
||||||
|
```
|
||||||
|
|
||||||
|
### TargetSelectorField 동작
|
||||||
|
```
|
||||||
|
대상 Select: [전사 | 부서별]
|
||||||
|
→ "부서별" 선택 시 → getDepartmentList() API 호출
|
||||||
|
→ 부서 Select 추가 표시: [개발팀 | 영업팀 | ...]
|
||||||
|
→ 부서 선택 시 form value = 'department:13'
|
||||||
|
```
|
||||||
498
claudedocs/[PLAN-2026-03-06] account-subject-unification.md
Normal file
498
claudedocs/[PLAN-2026-03-06] account-subject-unification.md
Normal file
@@ -0,0 +1,498 @@
|
|||||||
|
# 계정과목 통합 기획서
|
||||||
|
|
||||||
|
> 작성일: 2026-03-06
|
||||||
|
> 상태: 진행중
|
||||||
|
> 관련: `claudedocs/architecture/[ANALYSIS-2026-03-06] account-subject-comparison.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배경 및 목표
|
||||||
|
|
||||||
|
### 문제점
|
||||||
|
현재 계정과목이 **7개 모듈에서 각자 하드코딩**으로 관리되고 있음.
|
||||||
|
- 일반전표만 DB 마스터(account_codes) 사용, 나머지는 프론트 상수 배열
|
||||||
|
- 계정과목 등록은 일반전표 설정에서만 가능
|
||||||
|
- 분개 데이터가 3개 테이블에 분산 (journal_entries, hometax_invoice_journals, barobill_card_transactions)
|
||||||
|
- CEO 대시보드 비용 집계가 일반전표 분개에서만 작동
|
||||||
|
|
||||||
|
### 목표
|
||||||
|
1. **계정과목 마스터 통합**: 하나의 DB 테이블, 전 모듈 공유
|
||||||
|
2. **공용 컴포넌트**: 설정 모달(CRUD) + Select(조회) 2개로 전 모듈 대응
|
||||||
|
3. **분개 흐름 통합**: 모든 분개 → journal_entries 한 곳에 저장
|
||||||
|
4. **대시보드 정확도**: 어디서 분개하든 비용 집계 정상 작동
|
||||||
|
|
||||||
|
### 회계담당자 요구사항
|
||||||
|
- 계정과목을 번호 + 명칭으로 구분 (예: 5201 급여)
|
||||||
|
- 제조/회계 동일 명칭이지만 번호로 구분 가능해야 함
|
||||||
|
- 등록하면 전체 공유, 개별 등록도 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현재 상태 (AS-IS)
|
||||||
|
|
||||||
|
### 2.1 모듈별 계정과목 관리
|
||||||
|
|
||||||
|
| 모듈 | 소스 | 옵션 수 | 필드명 | API 필드 |
|
||||||
|
|------|------|---------|--------|----------|
|
||||||
|
| 일반전표입력 | DB 마스터 | 동적 | accountSubjectId | account_subject_id |
|
||||||
|
| 세금계산서관리 | 프론트 상수 | 11개 | accountSubject | account_subject |
|
||||||
|
| 카드사용내역 | 프론트 상수 | 16개 | accountSubject | account_code |
|
||||||
|
| 입금관리 | 프론트 상수 | ~11개 | depositType | account_code |
|
||||||
|
| 출금관리 | 프론트 상수 | ~11개 | withdrawalType | account_code |
|
||||||
|
| 미지급비용 | 프론트 상수 | 9개 | accountSubject | account_code |
|
||||||
|
| 매출관리 | 프론트 상수 | 8개 | accountSubject | account_code |
|
||||||
|
|
||||||
|
### 2.2 분개 저장 위치
|
||||||
|
|
||||||
|
| 소스 | 저장 테이블 | expense_accounts 동기화 |
|
||||||
|
|------|-----------|----------------------|
|
||||||
|
| 일반전표 (수기) | journal_entries + journal_entry_lines | O |
|
||||||
|
| 일반전표 (입출금 연동) | journal_entries + journal_entry_lines | O |
|
||||||
|
| 세금계산서 분개 | hometax_invoice_journals (별도) | X |
|
||||||
|
| 카드 계정과목 태그 | barobill_card_transactions.account_code | X |
|
||||||
|
|
||||||
|
### 2.3 백엔드 현재 테이블
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- account_codes (계정과목 마스터 - 일반전표만 사용)
|
||||||
|
id, tenant_id, code(10), name(100), category(enum), sort_order, is_active
|
||||||
|
|
||||||
|
-- journal_entries (분개 헤더)
|
||||||
|
id, tenant_id, entry_no, entry_date, entry_type, description,
|
||||||
|
total_debit, total_credit, status, source_type, source_key
|
||||||
|
|
||||||
|
-- journal_entry_lines (분개 상세)
|
||||||
|
id, journal_entry_id, tenant_id, line_no, account_code, account_name,
|
||||||
|
side(debit/credit), amount, trading_partner_id, trading_partner_name, description
|
||||||
|
|
||||||
|
-- hometax_invoice_journals (세금계산서 분개 - 별도)
|
||||||
|
id, tenant_id, hometax_invoice_id, nts_confirm_num,
|
||||||
|
dc_type, account_code, account_name, debit_amount, credit_amount, ...
|
||||||
|
|
||||||
|
-- barobill_card_transactions (카드 거래)
|
||||||
|
..., account_code, ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 목표 상태 (TO-BE)
|
||||||
|
|
||||||
|
### 3.1 통합 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[계정과목 마스터]
|
||||||
|
account_codes 테이블 (확장)
|
||||||
|
├── code: "5201"
|
||||||
|
├── name: "급여"
|
||||||
|
├── category: "expense"
|
||||||
|
├── sub_category: "selling_admin" (판관비)
|
||||||
|
├── parent_code: "52" (상위 그룹)
|
||||||
|
├── depth: 3 (대=1, 중=2, 소=3)
|
||||||
|
└── department_type: "common" (공통/제조/관리)
|
||||||
|
|
||||||
|
[분개 통합]
|
||||||
|
journal_entries (source_type으로 출처 구분)
|
||||||
|
├── source_type: 'manual' ← 수기 전표
|
||||||
|
├── source_type: 'bank_transaction' ← 입출금 연동
|
||||||
|
├── source_type: 'tax_invoice' ← 세금계산서 (신규)
|
||||||
|
└── source_type: 'card_transaction' ← 카드사용내역 (신규)
|
||||||
|
|
||||||
|
[프론트 공용 컴포넌트]
|
||||||
|
AccountSubjectSettingModal → 리스트 페이지에서 CRUD
|
||||||
|
AccountSubjectSelect → 세부 페이지/모달에서 선택
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 데이터 흐름 (TO-BE)
|
||||||
|
|
||||||
|
```
|
||||||
|
계정과목 등록 (어느 페이지에서든)
|
||||||
|
→ account_codes 테이블에 저장
|
||||||
|
→ 전 모듈에서 즉시 사용 가능
|
||||||
|
|
||||||
|
분개 입력 (어느 모듈에서든)
|
||||||
|
→ journal_entries + journal_entry_lines에 저장
|
||||||
|
→ account_code는 account_codes 마스터 참조
|
||||||
|
→ expense_accounts 자동 동기화 (복리후생비/접대비)
|
||||||
|
→ CEO 대시보드에 자동 반영
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Phase별 세부 구현 계획
|
||||||
|
|
||||||
|
### Phase 1: 백엔드 마스터 강화
|
||||||
|
|
||||||
|
#### 1-1. account_codes 테이블 확장 마이그레이션
|
||||||
|
|
||||||
|
```php
|
||||||
|
// database/migrations/2026_03_06_100000_enhance_account_codes_table.php
|
||||||
|
Schema::table('account_codes', function (Blueprint $table) {
|
||||||
|
$table->string('sub_category', 50)->nullable()->after('category')
|
||||||
|
->comment('중분류 (current_asset, fixed_asset, selling_admin, cogs 등)');
|
||||||
|
$table->string('parent_code', 10)->nullable()->after('sub_category')
|
||||||
|
->comment('상위 계정과목 코드 (계층 구조)');
|
||||||
|
$table->tinyInteger('depth')->default(3)->after('parent_code')
|
||||||
|
->comment('계층 깊이 (1=대분류, 2=중분류, 3=소분류)');
|
||||||
|
$table->string('department_type', 20)->default('common')->after('depth')
|
||||||
|
->comment('부문 (common=공통, manufacturing=제조, admin=관리)');
|
||||||
|
$table->string('description', 500)->nullable()->after('department_type')
|
||||||
|
->comment('계정과목 설명');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**sub_category 값 목록:**
|
||||||
|
|
||||||
|
| category | sub_category | 한글 |
|
||||||
|
|----------|-------------|------|
|
||||||
|
| asset | current_asset | 유동자산 |
|
||||||
|
| asset | fixed_asset | 비유동자산 |
|
||||||
|
| liability | current_liability | 유동부채 |
|
||||||
|
| liability | long_term_liability | 비유동부채 |
|
||||||
|
| capital | - | 자본 |
|
||||||
|
| revenue | sales_revenue | 매출 |
|
||||||
|
| revenue | other_revenue | 영업외수익 |
|
||||||
|
| expense | cogs | 매출원가 |
|
||||||
|
| expense | selling_admin | 판매비와관리비 |
|
||||||
|
| expense | other_expense | 영업외비용 |
|
||||||
|
|
||||||
|
**department_type 값:**
|
||||||
|
- `common`: 공통 (모든 부문에서 사용)
|
||||||
|
- `manufacturing`: 제조 (매출원가 계정)
|
||||||
|
- `admin`: 관리 (판관비 계정)
|
||||||
|
|
||||||
|
#### 1-2. AccountCode 모델 업데이트
|
||||||
|
|
||||||
|
```php
|
||||||
|
// app/Models/Tenants/AccountCode.php
|
||||||
|
protected $fillable = [
|
||||||
|
'tenant_id', 'code', 'name', 'category',
|
||||||
|
'sub_category', 'parent_code', 'depth', 'department_type',
|
||||||
|
'description', 'sort_order', 'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 상수
|
||||||
|
const DEPT_COMMON = 'common';
|
||||||
|
const DEPT_MANUFACTURING = 'manufacturing';
|
||||||
|
const DEPT_ADMIN = 'admin';
|
||||||
|
|
||||||
|
const DEPTH_MAJOR = 1; // 대분류
|
||||||
|
const DEPTH_MIDDLE = 2; // 중분류
|
||||||
|
const DEPTH_MINOR = 3; // 소분류
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1-3. AccountCodeService 확장
|
||||||
|
|
||||||
|
기존 CRUD에 추가:
|
||||||
|
- `getHierarchical()`: 계층 구조 조회 (대-중-소 트리)
|
||||||
|
- `getByCategory(category, sub_category?)`: 분류별 조회
|
||||||
|
- `getByDepartment(department_type)`: 부문별 조회
|
||||||
|
- 필터: category, sub_category, department_type, depth, search, is_active
|
||||||
|
|
||||||
|
#### 1-4. AccountSubjectController 확장
|
||||||
|
|
||||||
|
기존 엔드포인트 유지 + 확장:
|
||||||
|
```
|
||||||
|
GET /api/v1/account-subjects ← 기존 (필터 파라미터 확장)
|
||||||
|
?category=expense
|
||||||
|
&sub_category=selling_admin
|
||||||
|
&department_type=common
|
||||||
|
&depth=3
|
||||||
|
&search=급여
|
||||||
|
&is_active=true
|
||||||
|
&hierarchical=true ← 계층 구조 응답 옵션
|
||||||
|
|
||||||
|
POST /api/v1/account-subjects ← 기존 (새 필드 추가)
|
||||||
|
PATCH /api/v1/account-subjects/{id} ← 신규 (수정)
|
||||||
|
PATCH /api/v1/account-subjects/{id}/status ← 기존
|
||||||
|
DELETE /api/v1/account-subjects/{id} ← 기존
|
||||||
|
|
||||||
|
POST /api/v1/account-subjects/seed-defaults ← 신규 (기본 계정과목표 일괄 생성)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 1-5. 표준 계정과목표 시드 데이터
|
||||||
|
|
||||||
|
```
|
||||||
|
1xxx 자산
|
||||||
|
11xx 유동자산
|
||||||
|
1101 현금
|
||||||
|
1102 보통예금
|
||||||
|
1103 당좌예금
|
||||||
|
1110 매출채권(외상매출금)
|
||||||
|
1120 선급금
|
||||||
|
1130 미수금
|
||||||
|
1140 가지급금
|
||||||
|
12xx 비유동자산
|
||||||
|
1201 토지
|
||||||
|
1202 건물
|
||||||
|
1210 기계장치
|
||||||
|
1220 차량운반구
|
||||||
|
1230 비품
|
||||||
|
1240 보증금
|
||||||
|
|
||||||
|
2xxx 부채
|
||||||
|
21xx 유동부채
|
||||||
|
2101 매입채무(외상매입금)
|
||||||
|
2102 미지급금
|
||||||
|
2103 선수금
|
||||||
|
2104 예수금
|
||||||
|
2110 부가세예수금
|
||||||
|
2120 부가세대급금
|
||||||
|
22xx 비유동부채
|
||||||
|
2201 장기차입금
|
||||||
|
|
||||||
|
3xxx 자본
|
||||||
|
31xx 자본금
|
||||||
|
3101 자본금
|
||||||
|
32xx 잉여금
|
||||||
|
3201 이익잉여금
|
||||||
|
|
||||||
|
4xxx 수익
|
||||||
|
41xx 매출
|
||||||
|
4101 제품매출
|
||||||
|
4102 상품매출
|
||||||
|
4103 부품매출
|
||||||
|
4104 용역매출
|
||||||
|
4105 공사매출
|
||||||
|
4106 임대수익
|
||||||
|
42xx 영업외수익
|
||||||
|
4201 이자수익
|
||||||
|
4202 외환차익
|
||||||
|
|
||||||
|
5xxx 비용
|
||||||
|
51xx 매출원가 (제조)
|
||||||
|
5101 재료비 ← department: manufacturing
|
||||||
|
5102 노무비 ← department: manufacturing
|
||||||
|
5103 외주가공비 ← department: manufacturing
|
||||||
|
52xx 판매비와관리비 (관리)
|
||||||
|
5201 급여 ← department: admin
|
||||||
|
5202 복리후생비 ← department: admin
|
||||||
|
5203 접대비 ← department: admin
|
||||||
|
5204 세금과공과 ← department: admin
|
||||||
|
5205 감가상각비 ← department: admin
|
||||||
|
5206 임차료 ← department: admin
|
||||||
|
5207 보험료(4대보험) ← department: admin
|
||||||
|
5208 통신비 ← department: admin
|
||||||
|
5209 수도광열비 ← department: admin
|
||||||
|
5210 소모품비 ← department: admin
|
||||||
|
5211 여비교통비 ← department: admin
|
||||||
|
5212 차량유지비 ← department: admin
|
||||||
|
5213 운반비 ← department: admin
|
||||||
|
5214 재료비 ← department: admin (관리부문)
|
||||||
|
5220 경비 ← department: admin
|
||||||
|
53xx 영업외비용
|
||||||
|
5301 이자비용
|
||||||
|
5302 외환차손
|
||||||
|
5310 배당금지급
|
||||||
|
```
|
||||||
|
|
||||||
|
기존 하드코딩 옵션과의 매핑:
|
||||||
|
|
||||||
|
| 기존 하드코딩 (영문 키워드) | 매핑될 계정코드 |
|
||||||
|
|---------------------------|---------------|
|
||||||
|
| purchasePayment (매입대금) | 2101 매입채무 |
|
||||||
|
| advance (선급금) | 1120 선급금 |
|
||||||
|
| suspense (가지급금) | 1140 가지급금 |
|
||||||
|
| rent (임차료) | 5206 임차료 |
|
||||||
|
| salary (급여) | 5201 급여 |
|
||||||
|
| insurance (4대보험) | 5207 보험료 |
|
||||||
|
| tax (세금) | 5204 세금과공과 |
|
||||||
|
| utilities (공과금) | 5209 수도광열비 |
|
||||||
|
| expenses (경비) | 5220 경비 |
|
||||||
|
| salesRevenue (매출수금) | 4101~4106 매출 |
|
||||||
|
| accountsReceivable (외상매출금) | 1110 매출채권 |
|
||||||
|
| accountsPayable (외상매입금) | 2101 매입채무 |
|
||||||
|
| salesVat (부가세예수금) | 2110 부가세예수금 |
|
||||||
|
| purchaseVat (부가세대급금) | 2120 부가세대급금 |
|
||||||
|
| cashAndDeposits (현금및예금) | 1101~1103 현금/예금 |
|
||||||
|
| advanceReceived (선수금) | 2103 선수금 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 2: 프론트 공용 컴포넌트
|
||||||
|
|
||||||
|
#### 2-1. 파일 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/accounting/common/
|
||||||
|
├── types.ts # 공용 타입 정의
|
||||||
|
├── actions.ts # 공용 계정과목 API 함수
|
||||||
|
├── AccountSubjectSettingModal.tsx # 설정 모달 (CRUD)
|
||||||
|
└── AccountSubjectSelect.tsx # Select 컴포넌트 (조회/선택)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2-2. 공용 타입 (types.ts)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface AccountSubject {
|
||||||
|
id: string;
|
||||||
|
code: string; // "5201"
|
||||||
|
name: string; // "급여"
|
||||||
|
category: AccountCategory; // 'asset' | 'liability' | 'capital' | 'revenue' | 'expense'
|
||||||
|
subCategory: string | null;
|
||||||
|
parentCode: string | null;
|
||||||
|
depth: number; // 1=대, 2=중, 3=소
|
||||||
|
departmentType: string; // 'common' | 'manufacturing' | 'admin'
|
||||||
|
description: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select에서 표시할 때: `[${code}] ${name}` → "[5201] 급여"
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2-3. 공용 actions.ts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
'use server';
|
||||||
|
// 계정과목 조회 (Select용 - 활성만)
|
||||||
|
export async function getAccountSubjects(params?)
|
||||||
|
|
||||||
|
// 계정과목 CRUD (설정 모달용)
|
||||||
|
export async function createAccountSubject(data)
|
||||||
|
export async function updateAccountSubject(id, data)
|
||||||
|
export async function updateAccountSubjectStatus(id, isActive)
|
||||||
|
export async function deleteAccountSubject(id)
|
||||||
|
|
||||||
|
// 기본 계정과목표 일괄 생성
|
||||||
|
export async function seedDefaultAccountSubjects()
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2-4. AccountSubjectSettingModal (설정 모달)
|
||||||
|
|
||||||
|
기존 GeneralJournalEntry/AccountSubjectSettingModal 기반 확장:
|
||||||
|
- 계층 구조 표시 (번호대별 그룹핑 또는 들여쓰기)
|
||||||
|
- 대분류/중분류/부문 필터
|
||||||
|
- 등록: 코드 + 명칭 + 분류 + 중분류 + 부문
|
||||||
|
- 수정: 명칭, 분류, 상태
|
||||||
|
- 삭제: 미사용 계정만
|
||||||
|
- "기본 계정과목표 불러오기" 버튼 (초기 세팅용)
|
||||||
|
|
||||||
|
#### 2-5. AccountSubjectSelect (Select 컴포넌트)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface AccountSubjectSelectProps {
|
||||||
|
value: string; // 선택된 계정과목 code
|
||||||
|
onValueChange: (code: string) => void;
|
||||||
|
category?: AccountCategory; // 특정 분류만 표시
|
||||||
|
subCategory?: string; // 특정 중분류만 표시
|
||||||
|
departmentType?: string; // 특정 부문만 표시
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
size?: 'default' | 'sm';
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
사용 예시:
|
||||||
|
```tsx
|
||||||
|
// 세금계산서 분개 - 전체 계정과목
|
||||||
|
<AccountSubjectSelect value={row.accountCode} onValueChange={...} />
|
||||||
|
|
||||||
|
// 카드내역 - 비용 계정만
|
||||||
|
<AccountSubjectSelect value={...} onValueChange={...} category="expense" />
|
||||||
|
|
||||||
|
// 입금관리 - 수익 + 자산 계정
|
||||||
|
<AccountSubjectSelect value={...} onValueChange={...} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 3: 7개 모듈 전환
|
||||||
|
|
||||||
|
각 모듈에서:
|
||||||
|
1. 하드코딩 ACCOUNT_SUBJECT_OPTIONS 상수 **제거**
|
||||||
|
2. Radix Select → **AccountSubjectSelect** 교체
|
||||||
|
3. 리스트 페이지에 **설정 모달 버튼** 추가 (필요한 곳만)
|
||||||
|
4. API 저장 시 영문 키워드 → **계정코드(숫자)** 로 변경
|
||||||
|
|
||||||
|
#### 데이터 마이그레이션 고려
|
||||||
|
|
||||||
|
기존 데이터의 영문 키워드를 숫자 코드로 변환하는 마이그레이션 필요:
|
||||||
|
```php
|
||||||
|
// 예: barobill_card_transactions.account_code
|
||||||
|
// 'salary' → '5201'
|
||||||
|
// 'rent' → '5206'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 4: 분개 흐름 통합
|
||||||
|
|
||||||
|
#### 4-1. JournalEntry source_type 확장
|
||||||
|
|
||||||
|
```php
|
||||||
|
// JournalEntry 모델
|
||||||
|
const SOURCE_MANUAL = 'manual';
|
||||||
|
const SOURCE_BANK_TRANSACTION = 'bank_transaction';
|
||||||
|
const SOURCE_TAX_INVOICE = 'tax_invoice'; // 신규
|
||||||
|
const SOURCE_CARD_TRANSACTION = 'card_transaction'; // 신규
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-2. 세금계산서 분개 통합
|
||||||
|
|
||||||
|
현재: `/api/v1/tax-invoices/{id}/journal-entries` → hometax_invoice_journals 저장
|
||||||
|
변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장
|
||||||
|
|
||||||
|
- source_type = 'tax_invoice'
|
||||||
|
- source_key = 'tax_invoice_{id}'
|
||||||
|
- hometax_invoice_journals는 레거시 호환으로 유지 (향후 제거)
|
||||||
|
|
||||||
|
#### 4-3. 카드사용내역 분개 통합
|
||||||
|
|
||||||
|
현재: `/api/v1/card-transactions/{id}/journal-entries` → barobill_card_transaction_splits
|
||||||
|
변경: 같은 엔드포인트 → journal_entries + journal_entry_lines 저장
|
||||||
|
|
||||||
|
- source_type = 'card_transaction'
|
||||||
|
- source_key = 'card_{id}'
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Phase 5: 대시보드 연동
|
||||||
|
|
||||||
|
#### 5-1. expense_accounts 동기화 공용화
|
||||||
|
|
||||||
|
현재 GeneralJournalEntryService에만 있는 syncExpenseAccounts를:
|
||||||
|
- **JournalEntryService (공용)** 로 분리
|
||||||
|
- 모든 분개 저장/수정/삭제 시 자동 호출
|
||||||
|
- account_name에 '복리후생비' 또는 '접대비' 포함 → expense_accounts 동기화
|
||||||
|
|
||||||
|
#### 5-2. 검증
|
||||||
|
|
||||||
|
- 일반전표에서 복리후생비 분개 → 대시보드 반영 확인
|
||||||
|
- 세금계산서에서 복리후생비 분개 → 대시보드 반영 확인
|
||||||
|
- 카드내역에서 복리후생비 분개 → 대시보드 반영 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 작업 순서 및 의존성
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1: 백엔드 마스터 강화
|
||||||
|
├── 1-1. 마이그레이션 + 모델
|
||||||
|
├── 1-2. 서비스 + 컨트롤러
|
||||||
|
└── 1-3. 시드 데이터
|
||||||
|
↓
|
||||||
|
Phase 2: 프론트 공용 컴포넌트
|
||||||
|
├── 2-1. 공용 타입 + actions
|
||||||
|
├── 2-2. AccountSubjectSettingModal
|
||||||
|
└── 2-3. AccountSubjectSelect
|
||||||
|
↓
|
||||||
|
Phase 3: 7개 모듈 전환 ──────────── Phase 4: 분개 흐름 통합
|
||||||
|
├── 3-1. 일반전표 ├── 4-1. source_type 확장
|
||||||
|
├── 3-2. 세금계산서 ├── 4-2. 세금계산서 분개
|
||||||
|
├── 3-3. 카드사용내역 └── 4-3. 카드 분개
|
||||||
|
├── 3-4. 입금관리 ↓
|
||||||
|
├── 3-5. 출금관리 Phase 5: 대시보드 연동
|
||||||
|
├── 3-6. 미지급비용 ├── 5-1. 동기화 공용화
|
||||||
|
└── 3-7. 매출관리 └── 5-2. 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 리스크 및 주의사항
|
||||||
|
|
||||||
|
| 리스크 | 대응 |
|
||||||
|
|--------|------|
|
||||||
|
| 기존 데이터 마이그레이션 | 영문 키워드 → 숫자 코드 변환 마이그레이션 작성 |
|
||||||
|
| 하드코딩 의존 코드 | 엑셀 다운로드 등에서 label 변환 로직 확인 |
|
||||||
|
| API 하위호환 | 기존 엔드포인트 유지, 새 필드는 optional |
|
||||||
|
| 시드 데이터 중복 | tenant별 기존 데이터 확인 후 없는 것만 추가 |
|
||||||
285
claudedocs/[QA-2026-03-16] approval-module-qa-report.md
Normal file
285
claudedocs/[QA-2026-03-16] approval-module-qa-report.md
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
# 결재 모듈 QA 검증 보고서 및 수정 계획서
|
||||||
|
|
||||||
|
**작성일**: 2026-03-16
|
||||||
|
**검증 대상**: 결재관리 모듈 전체 (기안함, 결재함, 참조함, 완료함)
|
||||||
|
**검증 범위**: 문서 분류/양식 선택, 등록/수정/삭제, 벨리데이션, 파일업로드
|
||||||
|
**상태**: Phase 0~3 완료, 버그 수정 5건 완료 및 재검수 통과, Phase 2-B 미완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0: 문서 분류 / 양식 선택 검증 ✅ 완료
|
||||||
|
|
||||||
|
### 7개 카테고리, 17개 양식 전체 목록 확인
|
||||||
|
|
||||||
|
| 카테고리 | 양식 수 | 양식 목록 | 상태 |
|
||||||
|
|---------|--------|----------|------|
|
||||||
|
| 일반 (3) | 3 | 근태신청, 사유서, 품의서 | ✅ |
|
||||||
|
| 경비 (2) | 2 | 지출결의서, 비용견적서 | ✅ |
|
||||||
|
| 인사 (2) | 2 | 연차사용촉진 통지서 (1차), 연차사용촉진 통지서 (2차) | ✅ |
|
||||||
|
| 총무 (2) | 2 | 공문서, 이사회의사록 | ✅ |
|
||||||
|
| 재무 (1) | 1 | 견적서 | ✅ |
|
||||||
|
| 총무/기타 (2) | 2 | 위임장, 사용인감계 | ✅ |
|
||||||
|
| 증명서 (5) | 5 | 사직서, 위촉증명서, 경력증명서, 재직증명서, 사용인감계 | ✅ |
|
||||||
|
|
||||||
|
**결론**: 2단계 Select (카테고리 → 양식)이 정상 동작하며 모든 양식이 노출됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: 등록/수정/삭제 검증 ✅ 완료
|
||||||
|
|
||||||
|
### Phase 1-A: 일반 카테고리 ✅
|
||||||
|
|
||||||
|
#### 품의서 (proposal) — 전용 폼 ✅
|
||||||
|
|
||||||
|
| 테스트 항목 | 결과 | 비고 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| 양식 선택 → 폼 렌더링 | ✅ | 제목, 거래처, 내용, 사유, 예상비용, 첨부파일 |
|
||||||
|
| 미리보기 | ✅ | DocumentDetailModal에 정상 렌더링 |
|
||||||
|
| 벨리데이션 (결재선 미지정) | ✅ | "결재선을 지정해주세요" toast |
|
||||||
|
| 임시저장 | ✅ | AP-20260316-0001 발급 |
|
||||||
|
| 상신 | ✅ | AP-20260316-0002 발급, 결재대기 전환 |
|
||||||
|
| 수정 (기안함에서 클릭) | ✅ | 모든 필드 복원, 제목 변경 후 저장 성공 |
|
||||||
|
| 삭제 | ✅ | 확인 다이얼로그 후 삭제 성공 |
|
||||||
|
|
||||||
|
#### 근태신청 (attendance_request) — 동적 폼 ✅
|
||||||
|
|
||||||
|
| 테스트 항목 | 결과 | 비고 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| 양식 선택 → 폼 렌더링 | ✅ | DynamicFormRenderer 5필드 정상 |
|
||||||
|
| 미리보기 | ✅ | 동적 폼 미리보기 정상 |
|
||||||
|
| 임시저장 | ✅ | 부분 입력 시 성공 (빈 폼은 실패 — BUG #13) |
|
||||||
|
| 상신 | ✅ | 부분 입력으로도 상신 성공 |
|
||||||
|
|
||||||
|
#### 사유서 (reason_report) — 동적 폼 ✅
|
||||||
|
|
||||||
|
| 테스트 항목 | 결과 | 비고 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| 양식 선택 → 폼 렌더링 | ✅ | DynamicFormRenderer 정상 |
|
||||||
|
| 미리보기 | ✅ | 정상 |
|
||||||
|
|
||||||
|
### Phase 1-B: 경비 카테고리 ✅
|
||||||
|
|
||||||
|
#### 지출결의서 (expenseReport) — 전용 폼 ✅
|
||||||
|
|
||||||
|
| 테스트 항목 | 결과 | 비고 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| 양식 선택 → 폼 렌더링 | ✅ | 항목 추가/삭제 테이블, 카드 정보 |
|
||||||
|
| 미리보기 | ✅ | 정상 |
|
||||||
|
| 임시저장 → 수정 → 상신 | ✅ | 전체 CRUD 정상 |
|
||||||
|
|
||||||
|
#### 비용견적서 (expenseEstimate) — 전용 폼 ✅
|
||||||
|
|
||||||
|
| 테스트 항목 | 결과 | 비고 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| 양식 선택 → 폼 렌더링 | ✅ | 항목 테이블, 지출합계/계좌잔액/최종차액 자동계산 |
|
||||||
|
| 미리보기 | ✅ | 정상 |
|
||||||
|
| 임시저장 → 수정 → 상신 | ✅ | 전체 CRUD 정상 |
|
||||||
|
|
||||||
|
### Phase 1-C: 나머지 카테고리 ✅
|
||||||
|
|
||||||
|
| 카테고리 | 양식 | 렌더링 | 미리보기 | 비고 |
|
||||||
|
|---------|------|--------|---------|------|
|
||||||
|
| 인사 | 연차촉진 1차 | ✅ | ✅ | 전체 CRUD 테스트 완료 |
|
||||||
|
| 인사 | 연차촉진 2차 | ✅ | ✅ | |
|
||||||
|
| 총무 | 공문서 | ✅ | ✅ | |
|
||||||
|
| 재무 | 견적서 | ✅ | ✅ | |
|
||||||
|
| 총무/기타 | 이사회의사록 | ✅ | ✅ | |
|
||||||
|
| 총무/기타 | 위임장 | ✅ | ✅ | 경미 a11y 이슈 (BUG #12) |
|
||||||
|
| 증명서 | 사용인감계 | ✅ | ✅ | 경미 a11y 이슈 (BUG #12) |
|
||||||
|
| 증명서 | 사직서 | ✅ | ✅ | |
|
||||||
|
| 증명서 | 위촉증명서 | ✅ | ✅ | |
|
||||||
|
| 증명서 | 경력증명서 | ✅ | ✅ | |
|
||||||
|
| 증명서 | 재직증명서 | ✅ | ✅ | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 벨리데이션 체크 및 파일업로드 ✅ 완료
|
||||||
|
|
||||||
|
### 벨리데이션 테스트 결과
|
||||||
|
|
||||||
|
| 테스트 시나리오 | 결과 | 동작 |
|
||||||
|
|--------------|------|------|
|
||||||
|
| 결재자 미지정 → 상신 | ✅ | "결재선을 지정해주세요." toast (프론트엔드) |
|
||||||
|
| 결재자 미지정 + 빈 폼 → 상신 | ✅ | 결재선 검증이 먼저 작동 |
|
||||||
|
| 결재자 지정 + 빈 폼 → 상신 | ✅ | "내용은(는) 필수 항목입니다." toast (백엔드 API) |
|
||||||
|
| 빈 폼 → 임시저장 | ❌ BUG #13 | 백엔드가 임시저장에도 content 필수 검증 적용 |
|
||||||
|
| 부분 입력 → 임시저장 | ✅ | AP-20260316-0009 발급, 성공 |
|
||||||
|
| 부분 입력 → 상신 | ⚠️ | 성공하지만 필드별 검증 부재 (BUG #14) |
|
||||||
|
| 임시저장 반복 클릭 | ❌ BUG #11 | 매번 새 문서 생성 (중복) |
|
||||||
|
|
||||||
|
### 파일 업로드 테스트 결과
|
||||||
|
|
||||||
|
| 테스트 항목 | 결과 | 비고 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| FileDropzone 렌더링 (품의서) | ✅ | "클릭하거나 파일을 드래그하세요" |
|
||||||
|
| 이미지 파일 업로드 | ✅ | test-upload.png 정상 첨부 |
|
||||||
|
| 첨부 파일 표시 | ✅ | "test-upload.png (새 파일) 73 B" |
|
||||||
|
| 첨부 파일 삭제 | ✅ | 삭제 후 "첨부된 파일이 없습니다" 복원 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2-B: 대시보드 연동 검증 ⏳ 미완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 발견된 버그 목록 (전체)
|
||||||
|
|
||||||
|
### 🔴 CRITICAL
|
||||||
|
|
||||||
|
#### BUG #11: 임시저장 후 URL 미갱신 → 중복 문서 생성 + 삭제 불가
|
||||||
|
|
||||||
|
**증상**:
|
||||||
|
1. 새 문서 작성(`?mode=new`)에서 임시저장 성공 후 URL이 `?mode=new`로 유지
|
||||||
|
2. `isEditMode`가 false인 채로 유지됨
|
||||||
|
3. 임시저장을 다시 클릭하면 `createApproval()` 재호출 → **매번 새 문서 생성** (AP-0009, AP-0010...)
|
||||||
|
4. 삭제 버튼 클릭 시 `isEditMode`가 false이므로 API 호출 없이 `router.back()` 실행
|
||||||
|
|
||||||
|
**재현**: 새 문서 → 내용 입력 → 임시저장 → 임시저장 반복 → 기안함에서 중복 문서 확인
|
||||||
|
|
||||||
|
**파일**: `src/components/approval/DocumentCreate/index.tsx` lines 526-569
|
||||||
|
|
||||||
|
**수정 방안**:
|
||||||
|
```typescript
|
||||||
|
// handleSaveDraft 성공 후 URL 갱신 추가
|
||||||
|
if (result.success && result.data?.id) {
|
||||||
|
// URL을 edit 모드로 전환하여 이후 저장이 updateApproval을 호출하도록
|
||||||
|
router.replace(`/approval/draft/new?id=${result.data.id}&mode=edit`, { scroll: false });
|
||||||
|
// 또는 state로 관리
|
||||||
|
setDocumentId(String(result.data.id));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**우선순위**: 🔴 CRITICAL — 데이터 중복 생성, 삭제 불가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟡 MEDIUM
|
||||||
|
|
||||||
|
#### BUG #1: 상신 후 기안함 리다이렉트 시 목록 데이터 미로드
|
||||||
|
|
||||||
|
**증상**: 문서 상신 후 기안함으로 리다이렉트되지만 목록이 0건으로 표시. 새로고침 후 정상.
|
||||||
|
|
||||||
|
**파일**: `src/components/approval/DocumentCreate/index.tsx` (handleSubmit → router.push)
|
||||||
|
|
||||||
|
**수정 방안**: DraftBox의 데이터 로딩에 pathname 의존성 추가 또는 invalidate 후 딜레이
|
||||||
|
|
||||||
|
**우선순위**: 🟡 MEDIUM — 새로고침으로 해결 가능
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### BUG #13: 빈 폼 임시저장 시 백엔드 검증 에러
|
||||||
|
|
||||||
|
**증상**: 폼 필드를 하나도 입력하지 않은 상태에서 임시저장 클릭 시 "내용은(는) 필수 항목입니다." 에러
|
||||||
|
|
||||||
|
**원인**: 동적 폼의 `dynamicFormData`가 `{}`일 때 백엔드가 content 필수 검증 적용
|
||||||
|
|
||||||
|
**수정 방안**:
|
||||||
|
- 프론트엔드: 빈 폼일 때 프론트엔드에서 "최소 1개 필드를 입력해주세요" 안내
|
||||||
|
- 또는 백엔드: 임시저장(`is_submitted=false`) 시 content 필수 검증 제외
|
||||||
|
|
||||||
|
**우선순위**: 🟡 MEDIUM — 임시저장 UX 개선
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### BUG #14: 부분 입력 폼 상신 시 필드별 벨리데이션 미비
|
||||||
|
|
||||||
|
**증상**: 근태신청에서 신청자와 사유만 입력하고 신청유형/기간/일수 미입력 상태로 상신 성공
|
||||||
|
|
||||||
|
**원인**: 백엔드에서 `content` JSON 내부 필드별 필수값 검증을 하지 않음
|
||||||
|
|
||||||
|
**수정 방안**: 백엔드에서 양식별 required 필드 검증 추가 필요
|
||||||
|
|
||||||
|
**우선순위**: 🟡 MEDIUM — 불완전한 문서가 상신될 수 있음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟢 LOW
|
||||||
|
|
||||||
|
#### BUG #12: 폼 헤더에 로딩 텍스트 a11y 이슈
|
||||||
|
|
||||||
|
**증상**: PowerOfAttorneyForm, SealUsageForm에서 `<h3>` 안에 로딩 `<span>` 포함
|
||||||
|
- 로딩 중: "위임인 불러오는 중..." / "회사 정보 불러오는 중..."이 h3의 일부로 읽힘
|
||||||
|
- 로딩 완료 후: 정상
|
||||||
|
|
||||||
|
**파일**:
|
||||||
|
- `src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx` line 47
|
||||||
|
- `src/components/approval/DocumentCreate/SealUsageForm.tsx` line 101
|
||||||
|
|
||||||
|
**수정 방안**: 로딩 텍스트를 `<h3>` 외부로 이동하거나 `aria-hidden` 추가
|
||||||
|
|
||||||
|
**우선순위**: 🟢 LOW — 일시적 상태, 기능 영향 없음
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ 수정 완료 (이전 세션에서 해결)
|
||||||
|
|
||||||
|
| 버그 | 증상 | 수정 내용 |
|
||||||
|
|------|------|----------|
|
||||||
|
| BUG #2 (서버 hang) | startTransition + 서버 액션 deadlock | startTransition 제거, try/catch 패턴 적용 |
|
||||||
|
| BUG #3 (Select 경고) | controlled/uncontrolled 전환 | value에 undefined 사용 + key prop |
|
||||||
|
| BUG #7 (content empty) | 전용 폼 content가 빈 객체 | getDocumentContent()에 구조화된 데이터 추가 |
|
||||||
|
| BUG #8 (명칭 불일치) | "지출 예상 내역서" → "비용견적서" | 11개 파일 명칭 통일 |
|
||||||
|
| BUG #9 (key 중복 에러) | 저장된 항목 복원 시 id 누락 | transformApiToFormData()에 fallback ID 생성 |
|
||||||
|
| BUG #10 (null Input) | Input value에 null 전달 | `?? ''` null guard 추가 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 수정 우선순위 정리
|
||||||
|
|
||||||
|
| 순위 | 버그 | 심각도 | 수정 난이도 | 파일 |
|
||||||
|
|------|------|--------|-----------|------|
|
||||||
|
| 1 | BUG #11 (중복 문서 생성) | 🔴 CRITICAL | 낮음 | `DocumentCreate/index.tsx` |
|
||||||
|
| 2 | BUG #1 (리다이렉트 미로드) | 🟡 MEDIUM | 중간 | `DocumentCreate/index.tsx`, `DraftBox/index.tsx` |
|
||||||
|
| 3 | BUG #13 (빈 폼 임시저장) | 🟡 MEDIUM | 낮음 | `DocumentCreate/index.tsx` (프론트) 또는 백엔드 |
|
||||||
|
| 4 | BUG #14 (필드별 검증 미비) | 🟡 MEDIUM | 높음 | 백엔드 API |
|
||||||
|
| 5 | BUG #12 (a11y 로딩 텍스트) | 🟢 LOW | 낮음 | `PowerOfAttorneyForm.tsx`, `SealUsageForm.tsx` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테스트 데이터 정리 필요
|
||||||
|
|
||||||
|
QA 과정에서 생성된 테스트 문서:
|
||||||
|
- AP-20260316-0009 (근태신청, 임시저장) — 중복 1
|
||||||
|
- AP-20260316-0010 (근태신청, 임시저장) — 중복 2
|
||||||
|
- AP-20260316-0011 (근태신청, 결재대기) — 부분 입력 상신
|
||||||
|
- AP-20260316-0008 (연차촉진1차, 임시저장)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 버그 수정 및 재검수 결과 ✅ 완료
|
||||||
|
|
||||||
|
### 수정 완료 (2026-03-16 14:00)
|
||||||
|
|
||||||
|
| BUG | 수정 내용 | 재검수 결과 | 검증 방법 |
|
||||||
|
|-----|----------|-----------|----------|
|
||||||
|
| **#11** (중복 생성) | `savedDocId` state 추가, 첫 저장 후 `isEditMode` 전환 | ✅ PASS | 1차 저장 `createApproval()` → 2차 저장 `updateApproval(55, ...)` 확인 |
|
||||||
|
| **#1** (리다이렉트 미로드) | `router.back()` → `router.push('/approval/draft')` 변경 | ✅ PASS | 상신 후 기안함 9건 정상 로드, 토스트 표시 |
|
||||||
|
| **#13** (빈 폼 임시저장) | 프론트엔드 사전 검증 추가 (동적/전용 폼 내용 체크) | ✅ PASS | "문서 내용을 최소 1개 이상 입력해주세요" 토스트 표시 |
|
||||||
|
| **#14** (필수 필드 검증) | 프론트엔드 동적 폼 required 필드 검증 추가 | ✅ PASS | "필수 항목을 입력해주세요: 신청유형, 기간, 일수" 토스트 표시 |
|
||||||
|
| **#12** (a11y 로딩) | `<h3>` 내부 로딩 span → `<div>` wrapper로 sibling 분리 | ✅ PASS | heading에 로딩 텍스트 미포함 확인 |
|
||||||
|
|
||||||
|
### 수정 파일 목록
|
||||||
|
|
||||||
|
| 파일 | 수정 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| `src/components/approval/DocumentCreate/index.tsx` | BUG #11, #1, #13, #14 |
|
||||||
|
| `src/components/approval/DocumentCreate/PowerOfAttorneyForm.tsx` | BUG #12 |
|
||||||
|
| `src/components/approval/DocumentCreate/SealUsageForm.tsx` | BUG #12 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 QA 진행 상태
|
||||||
|
|
||||||
|
| Phase | 상태 | 비고 |
|
||||||
|
|-------|------|------|
|
||||||
|
| Phase 0: 문서 분류/양식 선택 | ✅ 완료 | 7카테고리 17양식 전체 확인 |
|
||||||
|
| Phase 1-A: 일반 카테고리 CRUD | ✅ 완료 | 품의서 전체 CRUD, 근태신청/사유서 렌더링+미리보기 |
|
||||||
|
| Phase 1-B: 경비 카테고리 CRUD | ✅ 완료 | 지출결의서, 비용견적서 전체 CRUD |
|
||||||
|
| Phase 1-C: 나머지 카테고리 | ✅ 완료 | 11개 양식 렌더링+미리보기 전체 통과 |
|
||||||
|
| Phase 2: 벨리데이션/파일업로드 | ✅ 완료 | 7개 벨리데이션 시나리오, 파일 업로드/삭제 테스트 |
|
||||||
|
| Phase 2-B: 대시보드 연동 | ⏳ 미완료 | |
|
||||||
|
| Phase 3: 버그 정리/수정 계획 | ✅ 완료 | 본 문서 |
|
||||||
|
| **버그 수정 + 재검수** | **✅ 완료** | **5건 수정, 5건 화면 재검수 통과** |
|
||||||
|
|
||||||
|
### QA 중 생성된 테스트 데이터
|
||||||
|
- AP-20260316-0012 (근태신청, 결재대기) — BUG #11 재검수용
|
||||||
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를 재사용하여 데이터 일관성 확보
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# claudedocs 문서 맵
|
# claudedocs 문서 맵
|
||||||
|
|
||||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-02-23)
|
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-03-09)
|
||||||
|
|
||||||
## 빠른 참조
|
## 빠른 참조
|
||||||
|
|
||||||
@@ -10,6 +10,13 @@
|
|||||||
| **[`[REF] technical-decisions.md`](./architecture/[REF]%20technical-decisions.md)** | 프로젝트 기술 결정 사항 (13개 항목) |
|
| **[`[REF] technical-decisions.md`](./architecture/[REF]%20technical-decisions.md)** | 프로젝트 기술 결정 사항 (13개 항목) |
|
||||||
| **[`[GUIDE] common-page-patterns.md`](./guides/[GUIDE]%20common-page-patterns.md)** | 공통 페이지 패턴 가이드 |
|
| **[`[GUIDE] common-page-patterns.md`](./guides/[GUIDE]%20common-page-patterns.md)** | 공통 페이지 패턴 가이드 |
|
||||||
|
|
||||||
|
## 주간 구현내역
|
||||||
|
|
||||||
|
| 기간 | 문서 |
|
||||||
|
|------|------|
|
||||||
|
| 2026-03-02 ~ 03-08 | **[`[IMPL-2026-03-08] frontend-weekly-0302-0308.md`](./%5BIMPL-2026-03-08%5D%20frontend-weekly-0302-0308.md)** |
|
||||||
|
| (백엔드 일별) | `backend/2026-03-02_구현내역.md` ~ `2026-03-08_구현내역.md` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 폴더 구조
|
## 폴더 구조
|
||||||
@@ -38,9 +45,11 @@ claudedocs/
|
|||||||
├── architecture/ # 아키텍처 & 시스템 & 기술 결정
|
├── architecture/ # 아키텍처 & 시스템 & 기술 결정
|
||||||
├── changes/ # 변경이력
|
├── changes/ # 변경이력
|
||||||
├── refactoring/ # 리팩토링 체크리스트
|
├── refactoring/ # 리팩토링 체크리스트
|
||||||
|
├── outbound/ # 출하/배차관리
|
||||||
├── vehicle/ # 차량관리
|
├── vehicle/ # 차량관리
|
||||||
├── material/ # 자재관리
|
├── material/ # 자재관리
|
||||||
├── approval/ # 결재관리
|
├── approval/ # 결재관리
|
||||||
|
├── backend/ # 백엔드 일별 구현내역
|
||||||
├── customer-center/ # 고객센터
|
├── customer-center/ # 고객센터
|
||||||
├── components/ # 컴포넌트 문서
|
├── components/ # 컴포넌트 문서
|
||||||
├── vercel/ # Vercel 배포
|
├── vercel/ # Vercel 배포
|
||||||
|
|||||||
45
claudedocs/api/[API-2026-03-10] calendar-bill-integration.md
Normal file
45
claudedocs/api/[API-2026-03-10] calendar-bill-integration.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# 어음 만기일 캘린더 연동
|
||||||
|
|
||||||
|
**날짜**: 2026-03-10
|
||||||
|
**범위**: Backend (CalendarService) + Frontend (CalendarSection)
|
||||||
|
|
||||||
|
## 변경 요약
|
||||||
|
|
||||||
|
대시보드 캘린더에 어음(Bill) 만기일을 5번째 데이터 소스로 추가.
|
||||||
|
기존 4개 소스(작업지시, 계약, 휴가, 범용일정)와 동일한 패턴.
|
||||||
|
|
||||||
|
## Backend 변경
|
||||||
|
|
||||||
|
### `app/Services/CalendarService.php`
|
||||||
|
|
||||||
|
- `use App\Models\Tenants\Bill` import 추가
|
||||||
|
- `getSchedules()`: `$type === 'bill'` 필터 조건 및 merge 추가
|
||||||
|
- `getBillSchedules()` 메서드 신규:
|
||||||
|
- `maturity_date` 기준 날짜 범위 필터
|
||||||
|
- `paymentComplete`, `dishonored` 상태 제외
|
||||||
|
- 아이템 형식: `bill_{id}`, `[만기] {거래처명} {금액}원`
|
||||||
|
- `type: 'bill'`, `isAllDay: true`
|
||||||
|
|
||||||
|
## Frontend 변경
|
||||||
|
|
||||||
|
### `src/lib/api/dashboard/types.ts`
|
||||||
|
- `CalendarScheduleType`에 `'bill'` 추가
|
||||||
|
|
||||||
|
### `src/components/business/CEODashboard/types.ts`
|
||||||
|
- `CalendarScheduleItem.type`에 `'bill'` 추가
|
||||||
|
- `CalendarTaskFilterType`에 `'bill'` 추가
|
||||||
|
|
||||||
|
### `src/components/business/CEODashboard/sections/CalendarSection.tsx`
|
||||||
|
- `SCHEDULE_TYPE_COLORS`: `bill: 'amber'`
|
||||||
|
- `SCHEDULE_TYPE_LABELS`: `bill: '어음'`
|
||||||
|
- `SCHEDULE_TYPE_BADGE_COLORS`: `bill: amber 배지 스타일`
|
||||||
|
- `TASK_FILTER_OPTIONS`: `{ value: 'bill', label: '어음' }`
|
||||||
|
- `ExtendedTaskFilterType`: `'bill'` 추가
|
||||||
|
- 모바일 리스트뷰 `colorMap`: `bill: 'bg-amber-500'`
|
||||||
|
|
||||||
|
## 검증 방법
|
||||||
|
|
||||||
|
1. 대시보드 캘린더에서 어음 만기일이 amber 색상 점으로 표시되는지 확인
|
||||||
|
2. 캘린더 필터에서 "어음" 선택 시 어음 일정만 필터링되는지 확인
|
||||||
|
3. 어음 만기일 클릭 시 `[만기] 거래처명 금액원` 형식으로 표시되는지 확인
|
||||||
|
4. 기존 일정(일정/발주/시공/기타) 정상 동작 확인
|
||||||
@@ -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 신규 | 모달 확장 |
|
||||||
|
| 🟡 중 | 매출 현황, 매입 현황, 시공 현황, 근태 현황 | 신규 섹션 |
|
||||||
|
| 🟡 중 | 생산 현황 | 복잡한 공정 집계 |
|
||||||
|
| 🟢 하 | 미출고 내역, 일별 매출/매입 | 단순 조회 |
|
||||||
122
claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md
Normal file
122
claudedocs/api/[CHANGELOG-2026-03-09] sam-api-daily-changes.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# sam-api 변경 내역 (2026-03-09)
|
||||||
|
|
||||||
|
총 **13개 커밋** (중복 1건 제외 실질 12건)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## feat: 신규 기능 (6건)
|
||||||
|
|
||||||
|
### 1. [database] codebridge 이관 완료 테이블 58개 삭제
|
||||||
|
- **커밋**: `28ae481` / `74e3c21` (동일 커밋 2건)
|
||||||
|
- **작업자**: 권혁성
|
||||||
|
- **변경 파일**: 마이그레이션 1개
|
||||||
|
- **내용**:
|
||||||
|
- sam DB → codebridge DB 이관 완료된 58개 테이블 DROP
|
||||||
|
- FK 체크 비활성화 후 일괄 삭제
|
||||||
|
- 복원 경로: `~/backups/sam_codebridge_tables_20260309.sql`
|
||||||
|
|
||||||
|
### 2. [결재] 테넌트 부트스트랩에 기본 결재 양식 자동 시딩
|
||||||
|
- **커밋**: `45a207d`
|
||||||
|
- **작업자**: 권혁성
|
||||||
|
- **변경 파일**: `RecipeRegistry.php`, `ApprovalFormsStep.php` (신규)
|
||||||
|
- **내용**:
|
||||||
|
- ApprovalFormsStep 신규 생성 (proposal, expenseReport, expenseEstimate, attendance_request, reason_report)
|
||||||
|
- RecipeRegistry STANDARD 레시피에 등록
|
||||||
|
- 테넌트 생성 시 자동 실행, 기존 테넌트는 `php artisan tenants:bootstrap --all`
|
||||||
|
|
||||||
|
### 3. [quality] 검사 상태 자동 재계산 + 수주처 선택 연동
|
||||||
|
- **커밋**: `3fc5f51`
|
||||||
|
- **작업자**: 권혁성
|
||||||
|
- **변경 파일**: `QualityDocumentLocation.php`, `QualityDocumentService.php`
|
||||||
|
- **내용**:
|
||||||
|
- 개소별 inspection_status를 검사 데이터 기반 자동 판정 (15개 판정필드 + 사진 유무 → pending/in_progress/completed)
|
||||||
|
- 문서 status를 개소 상태 집계로 자동 재계산
|
||||||
|
- transformToFrontend에 client_id 매핑 추가
|
||||||
|
|
||||||
|
### 4. [현황판/악성채권] 카드별 sub_label 추가
|
||||||
|
- **커밋**: `56c60ec`
|
||||||
|
- **작업자**: 유병철
|
||||||
|
- **변경 파일**: `BadDebtService.php`, `StatusBoardService.php`
|
||||||
|
- **내용**:
|
||||||
|
- BadDebtService: 카드별(전체/추심중/법적조치/회수완료) sub_labels 추가
|
||||||
|
- StatusBoardService: 악성채권(최다 금액 거래처명), 신규거래처(최근 등록 업체명), 결재(최근 결재 제목) sub_label 추가
|
||||||
|
|
||||||
|
### 5. [복리후생] 상세 조회 커스텀 날짜 범위 필터
|
||||||
|
- **커밋**: `60c4256`
|
||||||
|
- **작업자**: 유병철
|
||||||
|
- **변경 파일**: `WelfareController.php`, `WelfareService.php`
|
||||||
|
- **내용**:
|
||||||
|
- start_date, end_date 쿼리 파라미터 추가
|
||||||
|
- 커스텀 날짜 범위 지정 시 해당 범위로 일별 사용 내역 조회
|
||||||
|
- 미지정 시 기존 분기 기준 유지
|
||||||
|
|
||||||
|
### 6. [finance] 더존 Smart A 표준 계정과목 추가 시딩
|
||||||
|
- **커밋**: `1d5d161`
|
||||||
|
- **작업자**: 유병철
|
||||||
|
- **변경 파일**: 마이그레이션 1개 (467줄)
|
||||||
|
- **내용**:
|
||||||
|
- 기획서 14장 기준 누락분 보완
|
||||||
|
- tenant_id + code 중복 시 skip (기존 데이터 보호)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## fix: 버그 수정 (4건)
|
||||||
|
|
||||||
|
### 7. [현황판] 결재 카드 조회에 approvalOnly 스코프 추가
|
||||||
|
- **커밋**: `ee9f4d0`
|
||||||
|
- **작업자**: 유병철
|
||||||
|
- **변경 파일**: `StatusBoardService.php`
|
||||||
|
- **내용**: ApprovalStep 쿼리에 approvalOnly() 스코프 적용, 결재 유형만 필터링
|
||||||
|
|
||||||
|
### 8. [악성채권] tenant_id ambiguous 에러 + JOIN 컬럼 prefix 보완
|
||||||
|
- **커밋**: `3929c5f`, `ca259cc`
|
||||||
|
- **작업자**: 유병철
|
||||||
|
- **변경 파일**: `BadDebtService.php`, `StatusBoardService.php`
|
||||||
|
- **내용**:
|
||||||
|
- JOIN 쿼리에서 `bad_debts.tenant_id`로 테이블 명시
|
||||||
|
- is_active, status 컬럼에도 `bad_debts.` prefix 추가
|
||||||
|
|
||||||
|
### 9. [세금계산서] NOT NULL 컬럼 null 방어 처리
|
||||||
|
- **커밋**: `1861f4d`
|
||||||
|
- **작업자**: 유병철
|
||||||
|
- **변경 파일**: `TaxInvoiceService.php`
|
||||||
|
- **내용**: supplier/buyer corp_num, corp_name null→빈문자열 보정 (ConvertEmptyStringsToNull 미들웨어 대응)
|
||||||
|
|
||||||
|
### 10. [세금계산서] 매입/매출 방향별 필수값 조건 분리
|
||||||
|
- **커밋**: `c62e59a`
|
||||||
|
- **작업자**: 유병철
|
||||||
|
- **변경 파일**: `CreateTaxInvoiceRequest.php`
|
||||||
|
- **내용**: 매입(supplier 필수), 매출(buyer 필수) — `required → required_if:direction` 조건부 검증
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## refactor: 리팩토링 (1건)
|
||||||
|
|
||||||
|
### 11. [세금계산서/바로빌] ApiResponse::handle() 클로저 패턴 통일
|
||||||
|
- **커밋**: `e6f13e3`
|
||||||
|
- **작업자**: 유병철
|
||||||
|
- **변경 파일**: `BarobillSettingController.php`, `TaxInvoiceController.php`
|
||||||
|
- **내용**:
|
||||||
|
- 전체 액션 클로저 방식 전환 (show/save/testConnection, index/show/store/update/destroy/issue/bulkIssue/cancel/checkStatus/summary)
|
||||||
|
- 중간 변수 할당 제거, 일관된 응답 패턴 적용
|
||||||
|
- **-38줄** (91→40+27 구조 정리)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 영향받는 주요 서비스 파일
|
||||||
|
|
||||||
|
| 파일 | 변경 횟수 | 도메인 |
|
||||||
|
|------|----------|--------|
|
||||||
|
| `StatusBoardService.php` | 4회 | 현황판/대시보드 |
|
||||||
|
| `BadDebtService.php` | 3회 | 악성채권 |
|
||||||
|
| `TaxInvoiceService.php` | 1회 | 세금계산서 |
|
||||||
|
| `TaxInvoiceController.php` | 1회 | 세금계산서 |
|
||||||
|
| `QualityDocumentService.php` | 1회 | 품질검사 |
|
||||||
|
| `WelfareService.php` | 1회 | 복리후생 |
|
||||||
|
|
||||||
|
## 작업자별 커밋 수
|
||||||
|
|
||||||
|
| 작업자 | 커밋 수 | 주요 도메인 |
|
||||||
|
|--------|---------|-------------|
|
||||||
|
| 유병철 | 9건 | 현황판, 악성채권, 세금계산서, 복리후생, 계정과목 |
|
||||||
|
| 권혁성 | 4건 | DB 이관, 결재 시딩, 품질검사 |
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# 캘린더 신규 일정 타입 추가 (결제예정/납기/출고)
|
||||||
|
|
||||||
|
**작업일**: 2026-03-10
|
||||||
|
**목적**: CEO 대시보드 캘린더에서 자금/물류/납기 일정을 한눈에 파악
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 추가된 타입
|
||||||
|
|
||||||
|
| 타입 | 라벨 | 색상 | ID 형식 | 제목 형식 |
|
||||||
|
|------|------|------|---------|----------|
|
||||||
|
| `expected_expense` | 결제예정 | rose (분홍) | `expense_{id}` | `[결제] {거래처명} {금액}원` |
|
||||||
|
| `delivery` | 납기 | cyan (청록) | `delivery_{id}` | `[납기] {거래처명} {현장명 or 수주번호}` |
|
||||||
|
| `shipment` | 출고 | teal (틸) | `shipment_{id}` | `[출고] {거래처명} {현장명 or 출하번호}` |
|
||||||
|
|
||||||
|
## 제외 항목
|
||||||
|
|
||||||
|
| 항목 | 사유 |
|
||||||
|
|------|------|
|
||||||
|
| 미수금 입금 예정일 | `Deposit` 모델에 expected_date 필드 없음 → Phase 2 |
|
||||||
|
| 세금 납부 예정일 | 이미 CalendarScheduleStore + 상수로 orange 색상 표시 중 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 파일
|
||||||
|
|
||||||
|
### Backend (1파일)
|
||||||
|
|
||||||
|
**`app/Services/CalendarService.php`**
|
||||||
|
- import 추가: `Order`, `ExpectedExpense`, `Shipment`
|
||||||
|
- `getSchedules()`: 3개 merge 블록 추가 (`expected_expense`, `delivery`, `shipment`)
|
||||||
|
- 신규 private 메서드 3개:
|
||||||
|
- `getExpectedExpenseSchedules()` — `ExpectedExpense` 모델, `expected_payment_date`, `payment_status != 'paid'`
|
||||||
|
- `getDeliverySchedules()` — `Order` 모델, `delivery_date`, 활성 status_code 5개
|
||||||
|
- `getShipmentSchedules()` — `Shipment` 모델, `scheduled_date`, status in ('scheduled', 'ready')
|
||||||
|
|
||||||
|
### Frontend (3파일)
|
||||||
|
|
||||||
|
**`src/components/business/CEODashboard/types.ts`**
|
||||||
|
- `CalendarScheduleItem.type` union에 3개 타입 추가
|
||||||
|
- `CalendarTaskFilterType` union에 3개 타입 추가
|
||||||
|
|
||||||
|
**`src/lib/api/dashboard/types.ts`**
|
||||||
|
- `CalendarScheduleType` union에 3개 타입 추가
|
||||||
|
|
||||||
|
**`src/components/business/CEODashboard/sections/CalendarSection.tsx`**
|
||||||
|
- `SCHEDULE_TYPE_COLORS`: rose/cyan/teal 추가
|
||||||
|
- `SCHEDULE_TYPE_ROUTES`: 3개 라우트 추가
|
||||||
|
- `SCHEDULE_TYPE_LABELS`: 결제예정/납기/출고 추가
|
||||||
|
- `SCHEDULE_TYPE_BADGE_COLORS`: rose/cyan/teal 뱃지 스타일 추가
|
||||||
|
- `TASK_FILTER_OPTIONS`: 필터 드롭다운 옵션 3개 추가
|
||||||
|
- `ExtendedTaskFilterType`: `'bill'` 제거 (CalendarTaskFilterType에 이미 포함)
|
||||||
|
- `getScheduleLink()`: `expected_expense`는 목록 페이지만 이동 (상세 없음)
|
||||||
|
- 모바일 `colorMap`: 3개 dot 색상 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 라우트 매핑
|
||||||
|
|
||||||
|
| 타입 | 상세보기 클릭 시 이동 경로 | 비고 |
|
||||||
|
|------|--------------------------|------|
|
||||||
|
| `expected_expense` | `/ko/accounting/expected-expenses` | 목록 페이지 (상세 없음) |
|
||||||
|
| `delivery` | `/ko/sales/order-management-sales/{id}` | 수주 상세 |
|
||||||
|
| `shipment` | `/ko/outbound/shipments/{id}` | 출고 상세 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검수 결과 (2026-03-10)
|
||||||
|
|
||||||
|
- [x] 캘린더 '전체' 필터에서 결제예정 항목 표시
|
||||||
|
- [x] 필터 드롭다운에 결제예정/납기/출고 옵션 추가
|
||||||
|
- [x] 결제예정 필터 선택 시 해당 타입만 표시
|
||||||
|
- [x] 결제예정 상세보기 링크 동작
|
||||||
|
- [x] 결제예정 뱃지 rose 색상 표시
|
||||||
|
- [x] 기존 5개 타입 정상 동작
|
||||||
|
- [x] TypeScript 빌드 에러 없음
|
||||||
|
- [ ] 납기/출고 데이터 표시 (테스트 DB에 해당 날짜 데이터 없어 미확인 — 기능은 정상)
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# 전자결재 결재함 확장 및 연결문서 기능
|
||||||
|
|
||||||
|
> **작업일**: 2026-03-01 ~ 03-07
|
||||||
|
> **상태**: ✅ 완료
|
||||||
|
> **커밋**: 181352d7, 72cf5d86
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
결재함(ApprovalBox) API 연동, 연결문서(LinkedDocumentContent) 렌더링,
|
||||||
|
모바일 반응형 레이아웃 개선.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 결재함 API 연동
|
||||||
|
|
||||||
|
- [x] 결재함 목록: `GET /api/v1/approvals/inbox`
|
||||||
|
- [x] 결재함 통계: `GET /api/v1/approvals/inbox/summary`
|
||||||
|
- [x] 승인 처리: `POST /api/v1/approvals/{id}/approve`
|
||||||
|
- [x] 반려 처리: `POST /api/v1/approvals/{id}/reject`
|
||||||
|
- [x] 문서 상태 매핑: DRAFT → PENDING → APPROVED / REJECTED / CANCELLED
|
||||||
|
- [x] 결재함 상태 헬퍼 함수 추가
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/approval/ApprovalBox/actions.ts` (+123/-7)
|
||||||
|
- `src/components/approval/ApprovalBox/index.tsx` (+47/-1)
|
||||||
|
- `src/components/approval/ApprovalBox/types.ts` (+9/-1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 연결문서 기능 (LinkedDocumentContent) — 신규
|
||||||
|
|
||||||
|
검사성적서, 작업일지 등 문서관리 시스템의 문서를 결재 문서에 연결하여 렌더링.
|
||||||
|
|
||||||
|
- [x] `LinkedDocumentContent` 컴포넌트 신규 생성
|
||||||
|
- [x] `DocumentHeader` 컴포넌트 활용 (일관된 스타일)
|
||||||
|
- [x] 결재라인 / 상태배지 / 문서 메타정보 표시
|
||||||
|
- [x] `DocumentDetailModalV2`에 연결문서 렌더링 통합
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/approval/DocumentDetail/LinkedDocumentContent.tsx` (신규, +133)
|
||||||
|
- `src/components/approval/DocumentDetail/DocumentDetailModalV2.tsx`
|
||||||
|
- `src/components/approval/DocumentDetail/types.ts` (+27/-1)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 모바일 반응형 개선
|
||||||
|
|
||||||
|
- [x] `AuthenticatedLayout`: 사이드바/메인 콘텐츠 모바일 대응
|
||||||
|
- [x] `HeaderFavoritesBar`: 전면 재설계 (+315/-127)
|
||||||
|
- [x] `Sidebar`: 반응형 숨김/표시
|
||||||
|
- [x] `SearchableSelectionModal`: HTML 유효성 에러 수정
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/layouts/AuthenticatedLayout.tsx` (+12/-1)
|
||||||
|
- `src/components/layout/HeaderFavoritesBar.tsx` (+315/-127)
|
||||||
|
- `src/components/layout/Sidebar.tsx` (+8/-1)
|
||||||
|
- `src/components/organisms/SearchableSelectionModal.tsx` (+79/-2)
|
||||||
@@ -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만원
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# 계정과목(Chart of Accounts) 현황 분석 및 일반 ERP 비교
|
||||||
|
|
||||||
|
> 작성일: 2026-03-06
|
||||||
|
> 목적: 회계담당자 피드백 기반, 현재 시스템 vs 일반 ERP 계정과목 체계 비교
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 회계담당자 요구사항 요약
|
||||||
|
|
||||||
|
| # | 요구사항 | 핵심 |
|
||||||
|
|---|---------|------|
|
||||||
|
| 1 | 계정과목을 통일해서 관리 | 하나의 마스터에서 전사적 관리 |
|
||||||
|
| 2 | 번호와 명칭으로 구분 | 코드 체계 필수 (예: 401-매출, 501-급여) |
|
||||||
|
| 3 | 제조/회계 동일 명칭이지만 번호가 다른 경우 존재 | 부문별 세분화 필요 |
|
||||||
|
| 4 | 등록하면 전체가 공유 + 개별등록도 가능 | 공통 + 부문별 계정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 현재 시스템 계정과목 사용 현황
|
||||||
|
|
||||||
|
### 2.1 모듈별 계정과목 관리 방식
|
||||||
|
|
||||||
|
| 모듈 | 계정과목 소스 | 옵션 수 | 관리 방식 | API 필드명 |
|
||||||
|
|------|-------------|---------|----------|-----------|
|
||||||
|
| **일반전표입력** | DB 마스터 (account_codes) | 동적 | API CRUD | `account_subject_id` |
|
||||||
|
| **카드사용내역** | 프론트 하드코딩 | 16개 | 상수 배열 | `account_code` |
|
||||||
|
| **미지급비용** | 프론트 하드코딩 | 9개 | 상수 배열 | `account_code` |
|
||||||
|
| **매출관리** | 프론트 하드코딩 | 8개 | 상수 배열 | `account_code` |
|
||||||
|
| **입금관리** | 프론트 하드코딩 | ~11개 | depositType 상수 | `account_code` |
|
||||||
|
| **출금관리** | 프론트 하드코딩 | ~11개 | withdrawalType 상수 | `account_code` |
|
||||||
|
| **세금계산서관리** | 프론트 하드코딩 | 11개 | 상수 배열 (분개 모달) | `account_subject` |
|
||||||
|
| **CEO 대시보드** | 표시만 | - | account_title 표시 | `account_title` |
|
||||||
|
|
||||||
|
### 2.2 핵심 문제점
|
||||||
|
|
||||||
|
```
|
||||||
|
[문제 1] 계정과목 이원화
|
||||||
|
일반전표: DB 마스터 (code + name + category) ← 유일하게 정상
|
||||||
|
나머지: 프론트엔드 하드코딩 상수 배열 ← 각자 따로 관리
|
||||||
|
|
||||||
|
[문제 2] 코드 체계 불일치
|
||||||
|
일반전표: { code: "101", name: "현금", category: "asset" }
|
||||||
|
카드내역: { value: "purchasePayment", label: "매입대금" } ← 영문 키워드
|
||||||
|
입금관리: { value: "salesRevenue", label: "매출수금" } ← 또 다른 영문 키워드
|
||||||
|
|
||||||
|
[문제 3] 옵션 중복 + 불일치
|
||||||
|
"급여"가 카드내역(salary), 미지급비용(salary), 입출금(salary)에 각각 존재
|
||||||
|
세금계산서(분개)는 또 다른 옵션 세트 (매출, 부가세예수금 등)
|
||||||
|
하지만 서로 독립적이라 추가/수정 시 각 파일 개별 수정 필요
|
||||||
|
|
||||||
|
[문제 4] 번호 체계 없음
|
||||||
|
카드내역의 "매입대금" = 코드 없이 "purchasePayment"라는 문자열만 존재
|
||||||
|
제조에서 쓰는 "재료비"와 회계에서 쓰는 "재료비"를 구분할 방법 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 백엔드 DB 구조 (현재)
|
||||||
|
|
||||||
|
```
|
||||||
|
account_codes 테이블 (일반전표 전용 마스터)
|
||||||
|
├── id (PK)
|
||||||
|
├── tenant_id (테넌트 격리)
|
||||||
|
├── code (varchar 10) ← 계정번호
|
||||||
|
├── name (varchar 100) ← 계정명
|
||||||
|
├── category (enum: asset/liability/capital/revenue/expense)
|
||||||
|
├── sort_order
|
||||||
|
├── is_active
|
||||||
|
├── created_at / updated_at
|
||||||
|
└── unique(tenant_id, code)
|
||||||
|
|
||||||
|
journal_entry_lines (분개 상세)
|
||||||
|
├── account_code (varchar) ← 코드 저장
|
||||||
|
├── account_name (varchar) ← 명칭 스냅샷 저장
|
||||||
|
└── ... (side, amount 등)
|
||||||
|
|
||||||
|
barobill_card_transactions (카드거래)
|
||||||
|
├── account_code (varchar) ← 문자열 직접 저장 ("purchasePayment" 등)
|
||||||
|
└── ...
|
||||||
|
|
||||||
|
barobill_card_transaction_splits (카드 분개)
|
||||||
|
├── account_code (varchar) ← 문자열 직접 저장
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 일반적인 ERP의 계정과목(Chart of Accounts) 체계
|
||||||
|
|
||||||
|
### 3.1 표준 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
[계정과목표 = Chart of Accounts]
|
||||||
|
|
||||||
|
계정분류(대분류)
|
||||||
|
├── 1xxx: 자산 (Assets)
|
||||||
|
│ ├── 11xx: 유동자산
|
||||||
|
│ │ ├── 1101: 현금
|
||||||
|
│ │ ├── 1102: 보통예금
|
||||||
|
│ │ ├── 1103: 당좌예금
|
||||||
|
│ │ ├── 1110: 매출채권
|
||||||
|
│ │ └── 1120: 선급금
|
||||||
|
│ └── 12xx: 비유동자산
|
||||||
|
│ ├── 1201: 토지
|
||||||
|
│ ├── 1202: 건물
|
||||||
|
│ └── 1210: 기계장치
|
||||||
|
│
|
||||||
|
├── 2xxx: 부채 (Liabilities)
|
||||||
|
│ ├── 21xx: 유동부채
|
||||||
|
│ │ ├── 2101: 매입채무
|
||||||
|
│ │ ├── 2102: 미지급금
|
||||||
|
│ │ └── 2110: 예수금
|
||||||
|
│ └── 22xx: 비유동부채
|
||||||
|
│
|
||||||
|
├── 3xxx: 자본 (Equity)
|
||||||
|
│ ├── 3101: 자본금
|
||||||
|
│ └── 3201: 이익잉여금
|
||||||
|
│
|
||||||
|
├── 4xxx: 수익 (Revenue)
|
||||||
|
│ ├── 4101: 제품매출
|
||||||
|
│ ├── 4102: 상품매출
|
||||||
|
│ └── 4201: 임대수익
|
||||||
|
│
|
||||||
|
└── 5xxx: 비용 (Expenses)
|
||||||
|
├── 51xx: 매출원가
|
||||||
|
│ ├── 5101: 재료비 (제조) ← 코드로 구분!
|
||||||
|
│ └── 5102: 노무비
|
||||||
|
├── 52xx: 판매비와관리비
|
||||||
|
│ ├── 5201: 급여
|
||||||
|
│ ├── 5202: 복리후생비
|
||||||
|
│ ├── 5203: 접대비
|
||||||
|
│ ├── 5210: 재료비 (관리) ← 같은 명칭, 다른 코드!
|
||||||
|
│ └── 5220: 임차료
|
||||||
|
└── 53xx: 영업외비용
|
||||||
|
├── 5301: 이자비용
|
||||||
|
└── 5302: 외환차손
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 일반 ERP 계정과목 마스터 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
account_subjects (계정과목 마스터)
|
||||||
|
├── id (PK)
|
||||||
|
├── code (varchar 10) ← "5101" 같은 번호 (4~6자리)
|
||||||
|
├── name (varchar 100) ← "재료비"
|
||||||
|
├── category (대분류) ← 자산/부채/자본/수익/비용
|
||||||
|
├── sub_category (중분류) ← 유동자산/비유동자산/매출원가/판관비 등
|
||||||
|
├── parent_code (상위 계정) ← 계층 구조용
|
||||||
|
├── depth (계층 깊이) ← 1=대, 2=중, 3=소
|
||||||
|
├── department_type (부문) ← 제조/관리/공통 등
|
||||||
|
├── is_control (통제계정) ← 하위 세부계정 존재 여부
|
||||||
|
├── is_active (사용여부)
|
||||||
|
├── sort_order
|
||||||
|
├── description (설명)
|
||||||
|
└── tenant_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 일반 ERP vs 현재 SAM ERP 비교
|
||||||
|
|
||||||
|
| 항목 | 일반 ERP | SAM ERP (현재) | 차이 |
|
||||||
|
|------|---------|---------------|------|
|
||||||
|
| **마스터 테이블** | 1개 (전사 공유) | 1개 있지만 일반전표만 사용 | 다른 모듈 미연동 |
|
||||||
|
| **코드 체계** | 4~6자리 숫자 (1101, 5201) | 일반전표만 code 있음, 나머지 영문 키워드 | 번호 체계 불통일 |
|
||||||
|
| **계층 구조** | 대-중-소 분류 (parent_code) | 대분류(5개)만 존재 | 중/소분류 없음 |
|
||||||
|
| **부문 구분** | department_type으로 제조/관리 분리 | 없음 | 제조vs회계 구분 불가 |
|
||||||
|
| **공유 범위** | 전 모듈이 같은 마스터 참조 | 각 모듈 독자 관리 | 핵심 문제 |
|
||||||
|
| **등록 방식** | 계정과목 설정 화면 1곳 | 일반전표 설정에서만 등록 | 접근성 제한 |
|
||||||
|
| **사용처 추적** | 어떤 전표에서 사용되는지 추적 | 없음 | 감사 추적 불가 |
|
||||||
|
| **잠금/보호** | 사용 중인 계정 삭제 방지 | 없음 | 데이터 무결성 위험 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 담당자 요구사항 vs 현재 시스템 GAP 분석
|
||||||
|
|
||||||
|
### 요구 1: "계정과목을 통일해서 관리"
|
||||||
|
|
||||||
|
```
|
||||||
|
현재 상태:
|
||||||
|
일반전표 → account_codes 테이블 (DB)
|
||||||
|
세금계산서 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 11개) - 분개 모달
|
||||||
|
카드내역 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 16개)
|
||||||
|
미지급비용 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 9개)
|
||||||
|
매출관리 → ACCOUNT_SUBJECT_OPTIONS (프론트 상수, 8개)
|
||||||
|
입금관리 → depositType 상수
|
||||||
|
출금관리 → withdrawalType 상수
|
||||||
|
|
||||||
|
필요한 것:
|
||||||
|
모든 모듈 → account_codes 테이블 (DB) 하나만 참조
|
||||||
|
|
||||||
|
GAP: 크다 (프론트 하드코딩 → DB 마스터 참조로 전환 필요)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 요구 2: "번호와 명칭으로 구분"
|
||||||
|
|
||||||
|
```
|
||||||
|
현재 상태:
|
||||||
|
일반전표: code="101", name="현금" ← 있음
|
||||||
|
카드내역: value="salary", label="급여" ← 영문 키워드, 번호 없음
|
||||||
|
|
||||||
|
필요한 것:
|
||||||
|
모든 곳에서: code="5201", name="급여" 형태로 표시
|
||||||
|
UI에서: "5201 - 급여" 또는 "[5201] 급여" 식으로 코드+명칭 동시 표시
|
||||||
|
|
||||||
|
GAP: 중간 (코드 체계는 DB에 이미 있으나, 다른 모듈이 참조하지 않음)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 요구 3: "제조/회계 동일 명칭, 번호로 구분"
|
||||||
|
|
||||||
|
```
|
||||||
|
현재 상태:
|
||||||
|
구분 불가. "재료비"가 제조인지 관리인지 알 방법 없음
|
||||||
|
|
||||||
|
필요한 것:
|
||||||
|
5101: 재료비 (제조 - 매출원가)
|
||||||
|
5210: 재료비 (판관비 - 관리비용)
|
||||||
|
→ 코드가 다르므로 자동 구분
|
||||||
|
|
||||||
|
GAP: 크다 (중분류 + 부문 구분 필드 추가 필요)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 요구 4: "전체 공유 + 개별 등록 가능"
|
||||||
|
|
||||||
|
```
|
||||||
|
현재 상태:
|
||||||
|
일반전표 설정에서만 등록 가능. 다른 모듈은 하드코딩이라 등록 개념 없음.
|
||||||
|
|
||||||
|
필요한 것:
|
||||||
|
- 기본 계정과목표 (회사 설정 시 일괄 생성)
|
||||||
|
- 추가 등록 (필요에 따라 개별 계정과목 추가)
|
||||||
|
- 전 모듈 공유 (등록 즉시 카드, 입출금, 세금계산서 등에서 사용 가능)
|
||||||
|
|
||||||
|
GAP: 중간 (DB 마스터는 있으니, 다른 모듈이 참조하도록 연결만 하면 됨)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 결론 및 권장사항
|
||||||
|
|
||||||
|
### 5.1 담당자 말씀이 맞는가?
|
||||||
|
|
||||||
|
**맞습니다.** 일반적인 ERP에서 계정과목은 반드시:
|
||||||
|
- 하나의 마스터(Chart of Accounts)로 전사 통합 관리
|
||||||
|
- 숫자 코드 + 명칭으로 식별 (코드가 PK 역할)
|
||||||
|
- 코드 번호로 계정 분류/부문 구분 (제조 5101 vs 관리 5210)
|
||||||
|
- 한 번 등록하면 모든 회계 모듈에서 공유
|
||||||
|
|
||||||
|
현재 SAM ERP는 일반전표에만 정상적인 마스터가 있고, 나머지는 각자 하드코딩이므로
|
||||||
|
**회계적으로 올바르지 않은 상태**입니다.
|
||||||
|
|
||||||
|
### 5.2 개선 방향 (단계별)
|
||||||
|
|
||||||
|
```
|
||||||
|
[Phase 1] 계정과목 마스터 강화 (백엔드)
|
||||||
|
- account_codes 테이블에 sub_category, parent_code, depth, department_type 추가
|
||||||
|
- 표준 계정과목표 시드 데이터 준비 (대/중/소 분류)
|
||||||
|
- 코드 체계 확정 (4자리 vs 6자리)
|
||||||
|
|
||||||
|
[Phase 2] 계정과목 설정 화면 독립 (프론트)
|
||||||
|
- 일반전표 내부 모달 → 독립 메뉴로 분리 (회계 > 계정과목 설정)
|
||||||
|
- 계층 구조 표시 (트리뷰 또는 들여쓰기 목록)
|
||||||
|
- 대량 등록 (Excel import), 기본 계정과목표 초기 세팅
|
||||||
|
|
||||||
|
[Phase 3] 전 모듈 통합 (프론트 + 백엔드)
|
||||||
|
- 세금계산서관리: ACCOUNT_SUBJECT_OPTIONS 상수 (11개) → DB 마스터 API 호출로 전환
|
||||||
|
- 카드사용내역: ACCOUNT_SUBJECT_OPTIONS 상수 (16개) → DB 마스터 API 호출로 전환
|
||||||
|
- 입금/출금관리: depositType/withdrawalType → DB 마스터 참조로 전환
|
||||||
|
- 미지급비용, 매출관리: 동일하게 전환
|
||||||
|
- Select UI에 "코드 - 명칭" 형태로 표시 (예: "[5201] 급여")
|
||||||
|
|
||||||
|
[Phase 4] 고급 기능
|
||||||
|
- 사용중 계정 삭제 방지 (참조 무결성)
|
||||||
|
- 계정과목별 거래 내역 조회
|
||||||
|
- 기간별 잔액 집계
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 작업 규모 예상
|
||||||
|
|
||||||
|
| Phase | 범위 | 핵심 변경 |
|
||||||
|
|-------|------|----------|
|
||||||
|
| 1 | 백엔드 마이그레이션 + 시드 | account_codes 테이블 확장, 시드 데이터 |
|
||||||
|
| 2 | 프론트 1개 페이지 신규 | 계정과목 설정 독립 페이지 |
|
||||||
|
| 3 | 프론트 6~7개 모듈 수정, 백엔드 API 조정 | 하드코딩 → API 참조 전환 |
|
||||||
|
| 4 | 양쪽 추가 개발 | 무결성, 집계, 조회 |
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
# MES 데이터 정합성 심층 분석 보고서 v2
|
||||||
|
|
||||||
|
**분석일**: 2026-03-13 (v2 - 코드 업데이트 반영)
|
||||||
|
**범위**: 수주(Order) → 생산(Production) → 품질(Quality) → 출고(Shipment) 전체 파이프라인
|
||||||
|
**방법**: 프론트엔드(sam-react-prod) + 백엔드(sam-api) 코드 레벨 재분석
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1 대비 변경사항 요약
|
||||||
|
|
||||||
|
| 항목 | v1 (초기 분석) | v2 (코드 업데이트 반영) |
|
||||||
|
|------|---------------|----------------------|
|
||||||
|
| StockLot.work_order_id FK | 확인 안됨 | ✅ 2026-02-21 추가 확인 (생산→재고 연결 기반 마련) |
|
||||||
|
| QualityDocument 시스템 | 존재 인지 | ✅ 2026-03-05~10 활발히 개선 중 (inspection_data, options JSON 추가) |
|
||||||
|
| 출하 자동생성 | 언급 | ✅ 상세 분석 완료: createShipmentFromOrder() 중복방지 + ensureShipmentExists() |
|
||||||
|
| 3월 MES FK 추가 | 미확인 | ❌ 3월 마이그레이션에 MES FK 추가 없음 확인 |
|
||||||
|
| 나머지 4개 이슈 | 발견 | 🔴 여전히 미해결 (can_ship, LOT, ShipmentItem FK) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
| # | 이슈 | 심각도 | v1 판정 | v2 판정 | 변경 |
|
||||||
|
|---|------|--------|---------|---------|------|
|
||||||
|
| 1 | 생산완료→수주 PRODUCED 자동전환 | 🟡→🟢 | 조건부 동작 | **정상 동작 + 자동출하 생성** | ⬆ 개선 |
|
||||||
|
| 2 | 품질검사 이중 시스템 | 🔴 | 구조적 문제 | 🔴 **구조적 문제 지속** (QualityDocument 활발 개발 중이나 출고 연동 미완) | 유지 |
|
||||||
|
| 3 | 출고 시 can_ship 검증 | 🔴 | 누락 | 🔴 **여전히 누락** (canProceedToShip 호출 0회) | 유지 |
|
||||||
|
| 4 | 출고 시 재고 차감 | ✅ | 구현됨 | ✅ **구현됨**, ⚠️ soft fail 리스크 유지 | 유지 |
|
||||||
|
| 5 | LOT 추적 체계 | 🔴 | 단절 | 🟡 **부분 개선** (StockLot.work_order_id FK 추가, 그러나 LOT 전달 로직 미구현) | ⬆ 부분 |
|
||||||
|
| 6 | 출고품목↔수주품목 FK | 🔴 | 없음 | 🔴 **여전히 없음** (3월 마이그레이션에도 미추가) | 유지 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 1: 생산완료 → 수주 상태 자동전환 + 출하 자동생성
|
||||||
|
|
||||||
|
### v2 판정: 🟢 정상 동작 (v1 대비 상향)
|
||||||
|
|
||||||
|
v2 분석에서 자동 출하 생성 로직까지 상세 확인 완료. **정상 동작 확인**.
|
||||||
|
|
||||||
|
### 전체 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
WorkOrder 상태 변경 (updateStatus)
|
||||||
|
↓
|
||||||
|
syncOrderStatus() 자동 호출 (L971-1059)
|
||||||
|
↓
|
||||||
|
메인 WO 필터링: is_auxiliary=false AND process_id≠null
|
||||||
|
↓
|
||||||
|
전체 완료 시 → Order.status = PRODUCED
|
||||||
|
↓
|
||||||
|
createShipmentFromOrder() 자동 호출 (L719-809)
|
||||||
|
↓
|
||||||
|
Shipment 생성: status='scheduled', can_ship=true(자동)
|
||||||
|
↓
|
||||||
|
기존 Shipment 있으면 → 중복 생성 방지 (L721-728)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 코드 근거
|
||||||
|
|
||||||
|
**syncOrderStatus**: `WorkOrderService.php:971-1059`
|
||||||
|
|
||||||
|
```php
|
||||||
|
// L989-995: 메인 WO 필터 (보조공정 + process_id=null 제외)
|
||||||
|
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) =>
|
||||||
|
!$this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// L1001-1019: 상태 결정
|
||||||
|
if ($shippedCount === $totalCount) {
|
||||||
|
$newOrderStatus = Order::STATUS_SHIPPED;
|
||||||
|
} elseif (($completedCount + $shippedCount) === $totalCount) {
|
||||||
|
$newOrderStatus = Order::STATUS_PRODUCED;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**createShipmentFromOrder**: `WorkOrderService.php:719-809`
|
||||||
|
|
||||||
|
```php
|
||||||
|
// L721-728: 중복 방지
|
||||||
|
$existingShipment = Shipment::where('order_id', $order->id)->first();
|
||||||
|
if ($existingShipment) return $existingShipment;
|
||||||
|
|
||||||
|
// L732-744: 출하 자동 생성
|
||||||
|
$shipment = Shipment::create([
|
||||||
|
'order_id' => $order->id,
|
||||||
|
'work_order_id' => null, // 수주 레벨 (WO 레벨 아님)
|
||||||
|
'status' => 'scheduled',
|
||||||
|
'can_ship' => true, // ← 자동으로 true 설정
|
||||||
|
]);
|
||||||
|
|
||||||
|
// L746-790: WO 아이템 → ShipmentItem 복사
|
||||||
|
```
|
||||||
|
|
||||||
|
**ensureShipmentExists**: 이미 PRODUCED인데 출하가 없는 경우 보완 (L1027-1033)
|
||||||
|
|
||||||
|
### 잔존 리스크 (낮음)
|
||||||
|
|
||||||
|
| 조건 | 원인 | 발생 가능성 |
|
||||||
|
|------|------|------------|
|
||||||
|
| `process_id = NULL`인 WO | 공정 매핑 실패 | 낮음 (생성 시 검증됨) |
|
||||||
|
| `is_auxiliary` 오설정 | options JSON 수동 수정 | 매우 낮음 |
|
||||||
|
|
||||||
|
### 회의 논의 포인트
|
||||||
|
- ✅ 이 부분은 정상 동작 확인됨. 추가 조치 불필요
|
||||||
|
- (선택) process_id=null WO가 실데이터에 존재하는지 한번 쿼리 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 2: 품질검사 이중 시스템
|
||||||
|
|
||||||
|
### v2 판정: 🔴 구조적 문제 지속 (QualityDocument 활발 개발 중이나 출고 연동은 미완)
|
||||||
|
|
||||||
|
### v1 대비 변화
|
||||||
|
|
||||||
|
| 변경 사항 | 시기 | 내용 |
|
||||||
|
|-----------|------|------|
|
||||||
|
| `quality_document_locations.inspection_data` JSON 추가 | 2026-03-06 | 개소별 검사 데이터 저장 |
|
||||||
|
| `quality_document_locations.options` JSON 추가 | 2026-03-10 | 검사 옵션 확장 |
|
||||||
|
| QualityDocumentService 개선 | 2026-03 | inspectLocation() 등 기능 확장 |
|
||||||
|
|
||||||
|
### 여전히 해결 안 된 핵심 문제
|
||||||
|
|
||||||
|
```
|
||||||
|
QualityDocument.complete() 호출 시:
|
||||||
|
→ inspection_status = 'completed' (QualityDocument 내부만 업데이트)
|
||||||
|
→ ❌ Shipment.can_ship 업데이트 없음
|
||||||
|
→ ❌ Inspection 테이블 동기화 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
**두 시스템 현재 상태**:
|
||||||
|
|
||||||
|
| 항목 | 경로A: Inspection | 경로B: QualityDocument |
|
||||||
|
|------|-------------------|----------------------|
|
||||||
|
| **테이블** | `inspections` | `quality_documents` + `quality_document_locations` |
|
||||||
|
| **FK 연결** | `work_order_id` (작업지시) | `order_id` (수주) + `order_item_id` (개소) |
|
||||||
|
| **3월 업데이트** | 변경 없음 | ✅ 활발히 개선 중 |
|
||||||
|
| **출고 참조** | ❌ 안됨 | ❌ 안됨 |
|
||||||
|
|
||||||
|
### 회의 논의 포인트
|
||||||
|
- QualityDocument가 활발히 개발 중 → **경로B를 표준으로 확정하는 것이 합리적**
|
||||||
|
- 품질 완료 시 Shipment.can_ship 자동 업데이트 연동 필요
|
||||||
|
- 경로A(Inspection)는 IQC/PQC 전용으로 역할 한정, FQC는 경로B로 통일
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 3: 출고 시 can_ship 검증 누락
|
||||||
|
|
||||||
|
### v2 판정: 🔴 여전히 미해결 (canProceedToShip() 호출 0회 확인)
|
||||||
|
|
||||||
|
### 코드 현황 (변경 없음)
|
||||||
|
|
||||||
|
**canProceedToShip()**: `Shipment.php:220-223` — 정의만 존재
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function canProceedToShip(): bool {
|
||||||
|
return $this->can_ship && $this->deposit_confirmed;
|
||||||
|
}
|
||||||
|
// grep 결과: 모델 정의 외 호출 0회
|
||||||
|
```
|
||||||
|
|
||||||
|
**updateStatus()**: `ShipmentService.php:305-356` — can_ship 검증 없이 바로 업데이트
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment
|
||||||
|
{
|
||||||
|
$shipment = Shipment::findOrFail($id);
|
||||||
|
// 🔴 can_ship 검증 없음
|
||||||
|
$shipment->update(['status' => $status, ...]);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트엔드**: `ShipmentDetail.tsx:304-314` — can_ship 무시하고 버튼 표시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
|
||||||
|
scheduled: 'ready', ready: 'shipping', shipping: 'completed', completed: null,
|
||||||
|
};
|
||||||
|
// can_ship=false여도 상태 변경 버튼 표시됨
|
||||||
|
```
|
||||||
|
|
||||||
|
### v2 신규 발견: 자동 출하에서 can_ship=true 자동 설정
|
||||||
|
|
||||||
|
```php
|
||||||
|
// createShipmentFromOrder (L732-744)
|
||||||
|
'can_ship' => true, // 자동 생성 시 무조건 true
|
||||||
|
```
|
||||||
|
|
||||||
|
→ 자동 생성된 출하는 can_ship=true이므로 문제 경감
|
||||||
|
→ **그러나** 수동 생성 출하에서는 여전히 검증 없음
|
||||||
|
|
||||||
|
### 위험 시나리오
|
||||||
|
|
||||||
|
```
|
||||||
|
수동 출하 생성 (can_ship=false)
|
||||||
|
→ 사용자가 "출하대기" 클릭 → 검증 없이 ready
|
||||||
|
→ "배송중" → "배송완료" → 재고 차감 시도
|
||||||
|
→ 재고 부족 시 soft fail (로그만, 상태는 completed) ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수정안 (최소 변경)
|
||||||
|
|
||||||
|
**백엔드** (1곳 수정):
|
||||||
|
```php
|
||||||
|
// ShipmentService::updateStatus() 시작부에 추가
|
||||||
|
if (in_array($status, ['ready', 'shipping', 'completed']) && !$shipment->can_ship) {
|
||||||
|
throw new \Exception('출하 불가 상태입니다. 품질 검수를 완료해주세요.');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**프론트엔드** (1곳 수정):
|
||||||
|
```typescript
|
||||||
|
// ShipmentDetail.tsx 버튼 표시 조건
|
||||||
|
{STATUS_TRANSITIONS[detail.status] && detail.canShip && (
|
||||||
|
<Button onClick={handleOpenStatusDialog}>변경</Button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 4: 출고 시 재고 차감
|
||||||
|
|
||||||
|
### v2 판정: ✅ 구현됨, ⚠️ Soft Fail 리스크 유지 (변경 없음)
|
||||||
|
|
||||||
|
**코드**: `ShipmentService.php:361-401`
|
||||||
|
|
||||||
|
```php
|
||||||
|
private function decreaseStockForShipment(Shipment $shipment): void
|
||||||
|
{
|
||||||
|
foreach ($items as $item) {
|
||||||
|
try {
|
||||||
|
$stockService->decreaseForShipment(...);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// 🟡 SOFT FAIL: 로그만 기록, 출하 상태는 completed 유지
|
||||||
|
Log::warning('Failed to decrease stock', [...]);
|
||||||
|
// throw 없음 → 다음 아이템으로 계속
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회의 논의 포인트
|
||||||
|
- **Hard Fail 전환 여부**: `throw`로 변경하면 하나라도 실패 시 출하 전체 롤백
|
||||||
|
- **현재 방식 장점**: 일부 품목 재고 부족해도 출하는 진행 가능
|
||||||
|
- **권장**: 최소한 재고 차감 실패 건수를 프론트에 표시 + 관리자 알림
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 5: LOT 추적 체계
|
||||||
|
|
||||||
|
### v2 판정: 🟡 부분 개선 (v1 🔴 → v2 🟡)
|
||||||
|
|
||||||
|
### v1 대비 개선 사항
|
||||||
|
|
||||||
|
| 개선 | 시기 | 코드 근거 |
|
||||||
|
|------|------|-----------|
|
||||||
|
| `stock_lots.work_order_id` FK 추가 | 2026-02-21 | 마이그레이션 확인 |
|
||||||
|
| `inspections.work_order_id` FK 추가 | 2026-02-27 | 마이그레이션 확인 |
|
||||||
|
|
||||||
|
→ 재고↔생산, 검사↔생산 연결 **기반은 마련됨**
|
||||||
|
|
||||||
|
### 여전히 해결 안 된 핵심 문제
|
||||||
|
|
||||||
|
**1. 프론트에서 LOT 생성 → 백엔드 전송 안 됨**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WorkerScreen/actions.ts:246 — 프론트에서만 LOT 생성
|
||||||
|
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||||
|
// ← 이 값이 API 요청 body에 포함되지 않음
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 백엔드 LOT 저장 로직 없음**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// WorkOrderService.php:578-583
|
||||||
|
case WorkOrder::STATUS_COMPLETED:
|
||||||
|
$workOrder->completed_at = now();
|
||||||
|
$this->saveItemResults($workOrder, $resultData, $userId);
|
||||||
|
// ❌ LOT 자동 채번/저장 로직 없음
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
**3. 생산입고 시 LOT 전달 실패**
|
||||||
|
|
||||||
|
```php
|
||||||
|
// WorkOrderService.php:620-637
|
||||||
|
private function stockInFromProduction(WorkOrder $workOrder): void {
|
||||||
|
foreach ($workOrder->items as $woItem) {
|
||||||
|
$lotNo = $woItem->options['result']['lot_no'] ?? ''; // ← 항상 빈값
|
||||||
|
if ($goodQty > 0 && $lotNo) { // ← 조건 불충족 → 실행 안됨
|
||||||
|
$this->stockService->increaseFromProduction(...);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
→ **StockLot.work_order_id FK는 추가됐지만, 실제 LOT를 생성/저장하는 코드가 없어서 FK가 활용되지 않음**
|
||||||
|
|
||||||
|
### LOT 추적 현황 (업데이트)
|
||||||
|
|
||||||
|
```
|
||||||
|
수주 KD-TS-260313-01
|
||||||
|
→ 생산 완료 (LOT 미생성 ❌ — 프론트에서만 생성, 백엔드 저장 안됨)
|
||||||
|
→ 재고 입고 (LOT 전달 실패 ❌ — stockInFromProduction 조건 불충족)
|
||||||
|
→ [신규] StockLot.work_order_id FK 존재 (✅ 기반 마련)
|
||||||
|
→ 품질검사 (별도 LOT 입력 ⚠️)
|
||||||
|
→ 출고 (자재 LOT만 선택 가능 ❌, 생산 LOT 없음)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수정 방향 (StockLot.work_order_id 활용)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 1. 백엔드에서 LOT 자동 채번 (WorkOrderService)
|
||||||
|
$lotNo = $this->numberingService->generate('production-lot', $tenantId);
|
||||||
|
|
||||||
|
// 2. saveItemResults()에서 lot_no 저장
|
||||||
|
$woItem->options = array_merge($woItem->options, ['result' => ['lot_no' => $lotNo]]);
|
||||||
|
|
||||||
|
// 3. stockInFromProduction()에서 정상 동작 → StockLot 생성 시 work_order_id 연결
|
||||||
|
$this->stockService->increaseFromProduction(
|
||||||
|
lotNo: $lotNo,
|
||||||
|
workOrderId: $workOrder->id // ← 이미 FK 존재
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 6: 출고품목 ↔ 수주품목 FK 부재
|
||||||
|
|
||||||
|
### v2 판정: 🔴 여전히 미해결 (3월 마이그레이션에도 미추가 확인)
|
||||||
|
|
||||||
|
### ShipmentItem 실제 컬럼 (변경 없음)
|
||||||
|
|
||||||
|
```
|
||||||
|
id, tenant_id, shipment_id(FK), seq,
|
||||||
|
item_code, item_name, floor_unit, specification,
|
||||||
|
quantity, unit, lot_no, stock_lot_id(index only), remarks
|
||||||
|
```
|
||||||
|
|
||||||
|
- ❌ `order_item_id` → 없음
|
||||||
|
- ❌ `work_order_item_id` → 없음
|
||||||
|
|
||||||
|
### 3월 마이그레이션 확인 결과
|
||||||
|
|
||||||
|
3월에 추가된 마이그레이션 중 `shipment_items` 관련 변경 **0건**.
|
||||||
|
주요 3월 마이그레이션은 QualityDocument 관련 (`inspection_data`, `options` JSON 추가)에 집중.
|
||||||
|
|
||||||
|
### 추적 불가 질문들 (여전히)
|
||||||
|
|
||||||
|
| 질문 | 답변 가능 여부 |
|
||||||
|
|------|--------------|
|
||||||
|
| "출고 #1234에서 어떤 수주 품목이 출고됐나?" | ❌ 불가 |
|
||||||
|
| "수주 품목 #999는 어느 출고에서 출고됐나?" | ❌ 역추적 불가 |
|
||||||
|
| "수주 10개 품목 중 미출고 품목은?" | ❌ 집계 불가 |
|
||||||
|
| "부분 출고 진행률은?" | ❌ 계산 불가 |
|
||||||
|
|
||||||
|
### 자동 출하 생성 시 연결 기회 놓침
|
||||||
|
|
||||||
|
```php
|
||||||
|
// createShipmentFromOrder (L746-790)
|
||||||
|
// WO 아이템을 ShipmentItem으로 복사하면서 source 정보 저장 안 함
|
||||||
|
ShipmentItem::create([
|
||||||
|
'shipment_id' => $shipment->id,
|
||||||
|
'item_code' => $woItem->item_id ? "ITEM-{$woItem->item_id}" : null,
|
||||||
|
'quantity' => $result['good_qty'] ?? $woItem->quantity,
|
||||||
|
// ❌ 'order_item_id' => $woItem->source_order_item_id ← 이것만 추가하면 됨
|
||||||
|
// ❌ 'work_order_item_id' => $woItem->id ← 이것만 추가하면 됨
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 수정안
|
||||||
|
|
||||||
|
**마이그레이션** (새 파일):
|
||||||
|
```php
|
||||||
|
Schema::table('shipment_items', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('order_item_id')->nullable()->after('stock_lot_id');
|
||||||
|
$table->unsignedBigInteger('work_order_item_id')->nullable()->after('order_item_id');
|
||||||
|
$table->foreign('order_item_id')->references('id')->on('order_items')->nullOnDelete();
|
||||||
|
$table->foreign('work_order_item_id')->references('id')->on('work_order_items')->nullOnDelete();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**createShipmentFromOrder** (2줄 추가):
|
||||||
|
```php
|
||||||
|
ShipmentItem::create([
|
||||||
|
...기존 필드,
|
||||||
|
'order_item_id' => $woItem->source_order_item_id, // 추가
|
||||||
|
'work_order_item_id' => $woItem->id, // 추가
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 FK 연결 현황도 (v2 업데이트)
|
||||||
|
|
||||||
|
```
|
||||||
|
orders ──────────────────── order_items ──────── order_nodes
|
||||||
|
│ (order_id FK) │ (order_node_id FK) │
|
||||||
|
│ │ │
|
||||||
|
├─── work_orders │ │
|
||||||
|
│ │ (sales_order_id FK) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─── work_order_items │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ source_order_item_id ──→ ❌ FK 없음 (인덱스만)
|
||||||
|
│ │ │
|
||||||
|
│ inspections │
|
||||||
|
│ │ (work_order_id FK ✅) [2026-02-27 추가] │
|
||||||
|
│ │ (lot_no ← 연결 안됨 ❌) │
|
||||||
|
│ │
|
||||||
|
│ stock_lots │
|
||||||
|
│ │ (work_order_id FK ✅) [2026-02-21 추가] ← 🆕 v1에서 미확인
|
||||||
|
│ │
|
||||||
|
├─── quality_document_orders ──→ quality_documents │
|
||||||
|
│ │ (order_id FK ✅) │
|
||||||
|
│ │ │
|
||||||
|
│ └─── quality_document_locations │
|
||||||
|
│ │ (order_item_id FK ✅) │
|
||||||
|
│ │ (inspection_data JSON 🆕 2026-03-06) │
|
||||||
|
│ │ (options JSON 🆕 2026-03-10) │
|
||||||
|
│ │
|
||||||
|
└─── shipments │
|
||||||
|
│ (order_id FK ✅, work_order_id FK ✅) │
|
||||||
|
│ │
|
||||||
|
└─── shipment_items │
|
||||||
|
│ (shipment_id FK ✅) │
|
||||||
|
│ (stock_lot_id → 인덱스만, FK 없음) │
|
||||||
|
│ (order_item_id ❌ 컬럼 없음) │
|
||||||
|
│ (work_order_item_id ❌ 컬럼 없음) │
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개선 우선순위 로드맵 (v2 업데이트)
|
||||||
|
|
||||||
|
### P0 (즉시 - 운영 리스크) — 변경 없음
|
||||||
|
|
||||||
|
| # | 작업 | 수정 범위 | 난이도 |
|
||||||
|
|---|------|---------|--------|
|
||||||
|
| 1 | **can_ship 검증 추가** | ShipmentService::updateStatus() 1곳 + ShipmentDetail.tsx 1곳 | 하 (수정 3줄) |
|
||||||
|
| 2 | **재고 차감 실패 알림** | ShipmentService::decreaseStockForShipment() → 최소 결과 반환 | 하 |
|
||||||
|
|
||||||
|
### P1 (단기 - 데이터 정합성) — 🆕 StockLot.work_order_id 활용 추가
|
||||||
|
|
||||||
|
| # | 작업 | 수정 범위 | 난이도 |
|
||||||
|
|---|------|---------|--------|
|
||||||
|
| 3 | **생산 LOT 백엔드 자동 채번** | WorkOrderService::saveItemResults() + NumberingService | 중 |
|
||||||
|
| 4 | **생산입고 LOT 연결** | WorkOrderService::stockInFromProduction() → StockLot.work_order_id 활용 | 중 |
|
||||||
|
| 5 | **shipment_items에 order_item_id 추가** | 마이그레이션 + createShipmentFromOrder() 2줄 추가 | 중 |
|
||||||
|
|
||||||
|
### P2 (중기 - 구조 개선) — 🆕 QualityDocument 기반 통합 명시
|
||||||
|
|
||||||
|
| # | 작업 | 수정 범위 | 난이도 |
|
||||||
|
|---|------|---------|--------|
|
||||||
|
| 6 | **품질검사 정본 = QualityDocument** | Inspection은 IQC/PQC 전용, FQC는 QualityDocument로 통일 | 상 |
|
||||||
|
| 7 | **품질완료 → can_ship 자동 연동** | QualityDocumentService::complete() → Shipment.can_ship 업데이트 | 중 |
|
||||||
|
| 8 | **work_order_items.source_order_item_id FK** | 마이그레이션 1줄 | 하 |
|
||||||
|
| 9 | **stock_lot_id FK constraint 추가** | shipment_items 마이그레이션 | 하 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 정상 동작 확인 항목 (v2)
|
||||||
|
|
||||||
|
- ✅ 수주 → 생산지시 생성 (공정별 자동 분류)
|
||||||
|
- ✅ 작업지시 상태 관리 (유효 상태 전환 + auxiliary 필터링)
|
||||||
|
- ✅ **syncOrderStatus()**: 메인 WO 완료 → Order PRODUCED 자동 전환
|
||||||
|
- ✅ **createShipmentFromOrder()**: PRODUCED 전환 시 출하 자동 생성 (중복 방지 포함)
|
||||||
|
- ✅ **ensureShipmentExists()**: 이미 PRODUCED인데 출하 없는 경우 보완
|
||||||
|
- ✅ 자재 투입 재고 차감 (WorkOrderMaterialInput + StockService)
|
||||||
|
- ✅ 출고 완료 시 재고 차감 (FIFO + lockForUpdate + stock_transactions)
|
||||||
|
- ✅ 출고 완료 → 수주 상태 SHIPPED 자동 전환
|
||||||
|
- ✅ 매출 자동 생성 (sales_recognition 조건부)
|
||||||
|
- ✅ 수주 상태별 수정/삭제 제한
|
||||||
|
- ✅ 생산지시 되돌리기 (WorkOrder/Item/Result 삭제)
|
||||||
|
- 🆕 ✅ StockLot.work_order_id FK (생산→재고 연결 기반)
|
||||||
|
- 🆕 ✅ Inspection.work_order_id FK (검사→생산 연결)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 회의 토론 안건 정리
|
||||||
|
|
||||||
|
### 즉시 결정 필요 (P0)
|
||||||
|
|
||||||
|
1. **can_ship 검증**: 백엔드 1줄 + 프론트 1줄 수정으로 해결 가능. 즉시 적용?
|
||||||
|
2. **재고 차감 실패 처리**: Hard fail(롤백) vs Soft fail(현행) + 알림 추가?
|
||||||
|
|
||||||
|
### 설계 방향 결정 필요 (P1)
|
||||||
|
|
||||||
|
3. **LOT 채번 규칙**: 생산 LOT 형식 결정 (현재 프론트: `KD-SA-YYMMDD-NN`)
|
||||||
|
4. **생산 LOT 생성 시점**: WO 완료 시? WO 생성 시? 첫 작업 보고 시?
|
||||||
|
5. **ShipmentItem FK**: 마이그레이션 타이밍 (기존 데이터 소급 매칭 필요?)
|
||||||
|
|
||||||
|
### 방향성 논의 (P2)
|
||||||
|
|
||||||
|
6. **품질 시스템 정본**: QualityDocument를 표준으로 확정하는 것에 이견 있는지?
|
||||||
|
7. **품질→출하 자동 연동**: 어떤 조건에서 can_ship=true로 전환할 것인지?
|
||||||
|
- 전체 개소(location) 검사 완료 시?
|
||||||
|
- 합격률 기준?
|
||||||
|
- 수동 최종 승인 필요?
|
||||||
@@ -0,0 +1,421 @@
|
|||||||
|
# MES 데이터 정합성 심층 분석 보고서
|
||||||
|
|
||||||
|
**분석일**: 2026-03-13
|
||||||
|
**범위**: 수주(Order) → 생산(Production) → 품질(Quality) → 출고(Shipment) 전체 파이프라인
|
||||||
|
**방법**: 프론트엔드(sam-react-prod) + 백엔드(sam-api) 코드 레벨 분석
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
| # | 이슈 | 심각도 | 현황 | 코드 근거 |
|
||||||
|
|---|------|--------|------|-----------|
|
||||||
|
| 1 | 생산완료→수주 PRODUCED 자동전환 | 🟡 조건부 동작 | 로직 있으나 edge case에서 실패 가능 | `WorkOrderService.php:974-1062` |
|
||||||
|
| 2 | 품질검사 이중 시스템 | 🔴 구조적 문제 | Inspection vs QualityDocument 분리, 출고 연동 없음 | 양쪽 모두 Shipment 참조 안함 |
|
||||||
|
| 3 | 출고 시 can_ship 검증 | 🔴 누락 | canProceedToShip() 정의만 있고 호출 0회 | `ShipmentService.php:305-356` |
|
||||||
|
| 4 | 출고 시 재고 차감 | ✅ 구현됨 | completed 전환 시 FIFO 자동 차감 | `ShipmentService.php:361-401` |
|
||||||
|
| 5 | LOT 추적 체계 | 🔴 단절 | 프론트에서만 LOT 생성, 백엔드 저장 안됨 | `WorkerScreen/actions.ts:246` |
|
||||||
|
| 6 | 출고품목↔수주품목 FK | 🔴 없음 | ShipmentItem에 order_item_id 컬럼 자체 부재 | `shipment_items 마이그레이션` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 1: 생산완료 → 수주 상태 자동전환
|
||||||
|
|
||||||
|
### 결론: ✅ 로직 있음, 🟡 조건부 실패 가능
|
||||||
|
|
||||||
|
### 동작 원리
|
||||||
|
|
||||||
|
```
|
||||||
|
WorkOrder 상태 변경 (updateStatus)
|
||||||
|
↓ (라인 603)
|
||||||
|
syncOrderStatus() 자동 호출
|
||||||
|
↓ (라인 1004-1022)
|
||||||
|
메인 작업지시 집계 → 조건 충족 시 Order.status = PRODUCED
|
||||||
|
↓ (라인 1059-1061)
|
||||||
|
PRODUCED 전환 시 → 출고(Shipment) 자동 생성
|
||||||
|
```
|
||||||
|
|
||||||
|
**코드**: `sam-api/app/Services/WorkOrderService.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 라인 998: 메인 작업지시 필터
|
||||||
|
$mainWorkOrders = $allWorkOrders->filter(fn ($wo) =>
|
||||||
|
!$this->isAuxiliaryWorkOrder($wo) && $wo->process_id !== null
|
||||||
|
);
|
||||||
|
|
||||||
|
// 라인 1011-1022: 상태 결정
|
||||||
|
if ($shippedCount === $totalCount) {
|
||||||
|
$newOrderStatus = Order::STATUS_SHIPPED;
|
||||||
|
} elseif (($completedCount + $shippedCount) === $totalCount) {
|
||||||
|
$newOrderStatus = Order::STATUS_PRODUCED; // ← 핵심 조건
|
||||||
|
} elseif ($inProgressCount > 0 || $completedCount > 0 || $shippedCount > 0) {
|
||||||
|
$newOrderStatus = Order::STATUS_IN_PRODUCTION;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실패 가능 조건
|
||||||
|
|
||||||
|
| 조건 | 원인 | 영향 |
|
||||||
|
|------|------|------|
|
||||||
|
| `process_id = NULL`인 WO 존재 | 공정 매핑 실패로 생성된 작업지시 | 메인 WO 카운트에서 제외 → 조건식 계산 오류 |
|
||||||
|
| `is_auxiliary = true` 오설정 | options JSON에 잘못 저장 | 메인 WO로 인식 안 됨 |
|
||||||
|
|
||||||
|
### 검증 SQL
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 해당 수주의 작업지시 현황 확인
|
||||||
|
SELECT id, work_order_no, status, process_id,
|
||||||
|
JSON_EXTRACT(options, '$.is_auxiliary') as is_auxiliary
|
||||||
|
FROM work_orders
|
||||||
|
WHERE sales_order_id = {order_id} AND status != 'cancelled';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회의 논의 포인트
|
||||||
|
- process_id=null인 작업지시가 실제로 존재하는지 DB 확인 필요
|
||||||
|
- 존재한다면 → 생산지시 생성 시 process_id null 방지 로직 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 2: 품질검사 이중 시스템
|
||||||
|
|
||||||
|
### 결론: 🔴 두 시스템이 독립 운영, 출고와 연동 없음
|
||||||
|
|
||||||
|
### 두 시스템 비교
|
||||||
|
|
||||||
|
| 항목 | 경로A: Inspection | 경로B: QualityDocument |
|
||||||
|
|------|-------------------|----------------------|
|
||||||
|
| **테이블** | `inspections` | `quality_documents` + `quality_document_locations` |
|
||||||
|
| **생성일** | 2025-12-29 | 2026-03-05 (최근 추가) |
|
||||||
|
| **연결 키** | `work_order_id` (작업지시) | `order_id` (수주) + `order_item_id` (개소) |
|
||||||
|
| **판정 필드** | `result: pass/fail` | `inspection_status: pending/completed` |
|
||||||
|
| **검사 단위** | 전체 건 | 개소(location)별 |
|
||||||
|
| **프론트 진입점** | 검사 메뉴 | 제품검사 메뉴 |
|
||||||
|
| **FQC 문서** | JSON items 배열 | Document 시스템 (EAV) |
|
||||||
|
| **출고 참조** | ❌ 안됨 | ❌ 안됨 |
|
||||||
|
|
||||||
|
### 핵심 문제: 출고에서 둘 다 참조 안함
|
||||||
|
|
||||||
|
**코드**: `sam-api/app/Services/ShipmentService.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 라인 207: 출고 생성 시
|
||||||
|
'can_ship' => $data['can_ship'] ?? false, // ← 수동 입력만, 품질 검사 결과 참조 없음
|
||||||
|
|
||||||
|
// 라인 220-223: 출고 가능 여부 메서드
|
||||||
|
public function canProceedToShip(): bool {
|
||||||
|
return $this->can_ship && $this->deposit_confirmed;
|
||||||
|
// ❌ Inspection.result 참조 없음
|
||||||
|
// ❌ QualityDocumentLocation.inspection_status 참조 없음
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드 판정 우선순위
|
||||||
|
|
||||||
|
**코드**: `src/components/quality/InspectionManagement/InspectionDetail.tsx`
|
||||||
|
|
||||||
|
```
|
||||||
|
경로B (QualityDocument/FQC 문서) 우선 → 경로A (Inspection) fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회의 논의 포인트
|
||||||
|
- **정본 결정 필요**: 경로A(Inspection) vs 경로B(QualityDocument) 중 하나를 표준으로
|
||||||
|
- 경로B가 최근(3월) 추가된 것 → 경로B를 표준으로 하고 경로A는 호환 레이어?
|
||||||
|
- 출고 시 품질 판정 자동 참조 로직 추가 필수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 3: 출고 시 can_ship 검증 누락
|
||||||
|
|
||||||
|
### 결론: 🔴 canProceedToShip() 메서드 정의만 있고, 실제 호출 0회
|
||||||
|
|
||||||
|
### 현재 상태 변경 코드
|
||||||
|
|
||||||
|
**코드**: `sam-api/app/Services/ShipmentService.php:305-356`
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function updateStatus(int $id, string $status, ?array $additionalData = null): Shipment
|
||||||
|
{
|
||||||
|
$shipment = Shipment::where('tenant_id', $tenantId)->findOrFail($id);
|
||||||
|
|
||||||
|
// 🔴 can_ship 검증 로직 전혀 없음
|
||||||
|
|
||||||
|
$shipment->update(['status' => $status, ...]); // ← 바로 업데이트
|
||||||
|
|
||||||
|
// completed 시 재고 차감 (이것은 동작함)
|
||||||
|
if ($status === 'completed' && $previousStatus !== 'completed') {
|
||||||
|
$this->decreaseStockForShipment($shipment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 프론트엔드도 미검증
|
||||||
|
|
||||||
|
**코드**: `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx:304-314`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 상태 전이 맵만 확인, canShip 체크 없음
|
||||||
|
const STATUS_TRANSITIONS: Record<ShipmentStatus, ShipmentStatus | null> = {
|
||||||
|
scheduled: 'ready',
|
||||||
|
ready: 'shipping',
|
||||||
|
shipping: 'completed',
|
||||||
|
completed: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// can_ship=false여도 버튼이 표시됨 ❌
|
||||||
|
{STATUS_TRANSITIONS[detail.status] && (
|
||||||
|
<Button onClick={handleOpenStatusDialog}>변경</Button>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 위험 시나리오
|
||||||
|
|
||||||
|
```
|
||||||
|
can_ship=false (품질 미통과) + status=scheduled
|
||||||
|
→ 사용자가 "출하대기로 변경" 클릭
|
||||||
|
→ 백엔드 검증 없음 → status='ready' ❌
|
||||||
|
→ "배송중" → "배송완료" → 재고 차감 시도
|
||||||
|
→ 재고 부족 시 soft fail (로그만 기록, 상태는 변경됨) ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회의 논의 포인트
|
||||||
|
- 백엔드: `updateStatus()`에 `can_ship` 검증 추가 (1줄 수정)
|
||||||
|
- 프론트: 버튼 표시 조건에 `detail.canShip` 추가
|
||||||
|
- 재고 차감 실패 시 hard fail로 변경할지 논의 필요
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 4: 출고 시 재고 차감
|
||||||
|
|
||||||
|
### 결론: ✅ 완전 구현됨
|
||||||
|
|
||||||
|
**코드**: `sam-api/app/Services/ShipmentService.php:347-350`
|
||||||
|
|
||||||
|
```php
|
||||||
|
if ($status === 'completed' && $previousStatus !== 'completed') {
|
||||||
|
$this->decreaseStockForShipment($shipment);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**StockService FIFO 차감**: `StockService.php:1236-1354`
|
||||||
|
- Stock 행 잠금 (lockForUpdate)
|
||||||
|
- LOT별 FIFO 순서 차감
|
||||||
|
- stock_transactions 거래 기록 (reason: SHIPMENT)
|
||||||
|
- 감사 로그 기록
|
||||||
|
|
||||||
|
**⚠️ 주의**: 개별 품목 차감 실패 시 soft fail (로그만 기록, 트랜잭션 미롤백)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 5: LOT 추적 체계 단절
|
||||||
|
|
||||||
|
### 결론: 🔴 4개 모듈이 완전 독립적 LOT 관리, 추적 불가
|
||||||
|
|
||||||
|
### LOT 생성/관리 현황
|
||||||
|
|
||||||
|
| 모듈 | LOT 형식 | 생성 위치 | 저장 위치 | 상태 |
|
||||||
|
|------|----------|-----------|-----------|------|
|
||||||
|
| **수주** | - | - | Order에 lot_no 필드 없음 | ❌ 필드 없음 |
|
||||||
|
| **생산** | `KD-SA-YYMMDD-NN` | 프론트 `WorkerScreen/actions.ts:246` | ❌ 백엔드 전송 안됨 | ❌ 저장 안됨 |
|
||||||
|
| **자재** | 입고 시 생성 | `StockService` | `stock_lots.lot_no` | ✅ 동작 |
|
||||||
|
| **품질** | 검사팀 별도 입력 | `InspectionService` | `inspections.lot_no` | ⚠️ 연결 없음 |
|
||||||
|
| **출고** | StockLot에서 선택 | `ShipmentService:getLotOptions()` | `shipments.lot_no` | ⚠️ 자재 LOT만 |
|
||||||
|
|
||||||
|
### 핵심 단절 코드
|
||||||
|
|
||||||
|
**프론트에서 LOT 생성하지만 전송 안 함**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// WorkerScreen/actions.ts:246
|
||||||
|
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||||
|
// ← 이 값이 API 요청에 포함되지 않음
|
||||||
|
```
|
||||||
|
|
||||||
|
**백엔드에서 LOT 저장 안 함**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// WorkOrderService.php:578-583
|
||||||
|
case WorkOrder::STATUS_COMPLETED:
|
||||||
|
$workOrder->completed_at = now();
|
||||||
|
$this->saveItemResults($workOrder, $resultData, $userId);
|
||||||
|
// ❌ LOT 생성/저장 로직 없음
|
||||||
|
break;
|
||||||
|
```
|
||||||
|
|
||||||
|
**생산입고 시 LOT 전달 실패**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// WorkOrderService.php:620-637
|
||||||
|
private function stockInFromProduction(WorkOrder $workOrder): void {
|
||||||
|
foreach ($workOrder->items as $woItem) {
|
||||||
|
$lotNo = $woItem->options['result']['lot_no'] ?? ''; // ← 항상 빈값
|
||||||
|
if ($goodQty > 0 && $lotNo) { // ← 조건 불충족으로 실행 안됨
|
||||||
|
$this->stockService->increaseFromProduction(...);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**출고 LOT 옵션에서 생산 LOT 제외**:
|
||||||
|
|
||||||
|
```php
|
||||||
|
// ShipmentService.php:525-550
|
||||||
|
public function getLotOptions(): array {
|
||||||
|
return StockLot::where(...) // ← 구매입고 LOT만 조회
|
||||||
|
->whereIn('status', ['available', 'reserved'])
|
||||||
|
->get();
|
||||||
|
// ❌ 생산 완료 LOT(KD-SA-*) 미포함
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 추적 불가 시나리오
|
||||||
|
|
||||||
|
```
|
||||||
|
수주 KD-TS-260313-01
|
||||||
|
→ 생산 완료 (LOT 미생성)
|
||||||
|
→ 재고 입고 (LOT 전달 실패 → 입고 안됨?)
|
||||||
|
→ 품질검사 (별도 LOT 입력)
|
||||||
|
→ 출고 (자재 LOT만 선택 가능, 생산품 LOT 없음)
|
||||||
|
|
||||||
|
결과: "이 출고 건이 어느 생산 LOT인지" → 답 불가
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회의 논의 포인트
|
||||||
|
- **최우선**: 백엔드에서 생산 LOT 자동 채번/저장 로직 구현
|
||||||
|
- WorkResult.lot_no에 실제 저장
|
||||||
|
- StockLot.work_order_id (이미 2026-02-21 추가됨) 활용하여 연결
|
||||||
|
- getLotOptions()에 생산 LOT 포함
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이슈 6: 출고품목 ↔ 수주품목 FK 부재
|
||||||
|
|
||||||
|
### 결론: 🔴 ShipmentItem에 order_item_id, work_order_item_id 컬럼 자체가 없음
|
||||||
|
|
||||||
|
### ShipmentItem 실제 컬럼
|
||||||
|
|
||||||
|
**마이그레이션**: `2025_12_26_150605_create_shipment_items_table.php`
|
||||||
|
|
||||||
|
```
|
||||||
|
id, tenant_id, shipment_id(FK), seq,
|
||||||
|
item_code, item_name, floor_unit, specification,
|
||||||
|
quantity, unit, lot_no, stock_lot_id(FK), remarks
|
||||||
|
```
|
||||||
|
|
||||||
|
- ❌ `order_item_id` → 없음
|
||||||
|
- ❌ `work_order_item_id` → 없음
|
||||||
|
- 품목 데이터는 **텍스트 복사**만 (품명, 규격, 수량)
|
||||||
|
|
||||||
|
### ShipmentItem 생성 코드
|
||||||
|
|
||||||
|
**코드**: `sam-api/app/Services/ShipmentService.php:468-493`
|
||||||
|
|
||||||
|
```php
|
||||||
|
ShipmentItem::create([
|
||||||
|
'item_code' => $item['item_code'] ?? null,
|
||||||
|
'item_name' => $item['item_name'],
|
||||||
|
'quantity' => $item['quantity'] ?? 0,
|
||||||
|
// ❌ order_item_id 없음
|
||||||
|
// ❌ work_order_item_id 없음
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 추적 불가 질문들
|
||||||
|
|
||||||
|
| 질문 | 답변 가능 여부 |
|
||||||
|
|------|--------------|
|
||||||
|
| "출고 #1234에서 어떤 수주 품목이 출고됐나?" | ❌ 불가 |
|
||||||
|
| "수주 품목 #999는 어느 출고에서 출고됐나?" | ❌ 역추적 불가 |
|
||||||
|
| "수주 10개 품목 중 미출고 품목은?" | ❌ 집계 불가 |
|
||||||
|
| "부분 출고 진행률은?" | ❌ 계산 불가 |
|
||||||
|
|
||||||
|
### 관련 FK도 불완전
|
||||||
|
|
||||||
|
**WorkOrderItem.source_order_item_id**: 인덱스만 있고 FK constraint 없음
|
||||||
|
|
||||||
|
```php
|
||||||
|
// 마이그레이션 2026_01_16
|
||||||
|
$table->unsignedBigInteger('source_order_item_id')->nullable();
|
||||||
|
$table->index('source_order_item_id'); // ← 인덱스만
|
||||||
|
// ❌ $table->foreign('source_order_item_id')->references('id')->on('order_items') 없음
|
||||||
|
```
|
||||||
|
|
||||||
|
### 회의 논의 포인트
|
||||||
|
- shipment_items에 `order_item_id`, `work_order_item_id` 컬럼 추가 마이그레이션
|
||||||
|
- 기존 데이터 마이그레이션 방안 (품명+규격으로 매칭?)
|
||||||
|
- work_order_items.source_order_item_id에 FK constraint 추가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 전체 FK 연결 현황도
|
||||||
|
|
||||||
|
```
|
||||||
|
orders ──────────────────── order_items ──────── order_nodes
|
||||||
|
│ (order_id FK) │ (order_node_id FK) │
|
||||||
|
│ │ │
|
||||||
|
├─── work_orders │ │
|
||||||
|
│ │ (sales_order_id FK) │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └─── work_order_items │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ source_order_item_id ──→ ❌ FK 없음 (인덱스만)
|
||||||
|
│ │ │
|
||||||
|
│ inspections │
|
||||||
|
│ │ (work_order_id FK ✅) │
|
||||||
|
│ │ (lot_no ← 연결 안됨 ❌) │
|
||||||
|
│ │
|
||||||
|
├─── quality_document_orders ──→ quality_documents │
|
||||||
|
│ │ (order_id FK ✅) │
|
||||||
|
│ │ │
|
||||||
|
│ └─── quality_document_locations │
|
||||||
|
│ │ (order_item_id FK ✅) │
|
||||||
|
│ │
|
||||||
|
└─── shipments │
|
||||||
|
│ (order_id FK ✅, work_order_id FK ✅) │
|
||||||
|
│ │
|
||||||
|
└─── shipment_items │
|
||||||
|
│ (shipment_id FK ✅) │
|
||||||
|
│ (stock_lot_id FK ✅) │
|
||||||
|
│ (order_item_id ❌ 없음) │
|
||||||
|
│ (work_order_item_id ❌ 없음) │
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개선 우선순위 로드맵
|
||||||
|
|
||||||
|
### P0 (즉시 - 운영 리스크)
|
||||||
|
|
||||||
|
| # | 작업 | 영향범위 | 예상 난이도 |
|
||||||
|
|---|------|---------|------------|
|
||||||
|
| 1 | **can_ship 검증 추가** (백엔드 updateStatus + 프론트 버튼 조건) | ShipmentService 1곳 + ShipmentDetail 1곳 | 하 |
|
||||||
|
| 2 | **재고 차감 실패 시 hard fail** (try-catch에서 throw로 변경) | ShipmentService 1곳 | 하 |
|
||||||
|
|
||||||
|
### P1 (단기 - 데이터 정합성)
|
||||||
|
|
||||||
|
| # | 작업 | 영향범위 | 예상 난이도 |
|
||||||
|
|---|------|---------|------------|
|
||||||
|
| 3 | **생산 LOT 백엔드 자동 채번/저장** | WorkOrderService + NumberingService | 중 |
|
||||||
|
| 4 | **생산입고 LOT 연결 수정** (stockInFromProduction) | WorkOrderService + StockService | 중 |
|
||||||
|
| 5 | **shipment_items에 order_item_id 추가** (마이그레이션 + 서비스) | 마이그레이션 + ShipmentService | 중 |
|
||||||
|
|
||||||
|
### P2 (중기 - 구조 개선)
|
||||||
|
|
||||||
|
| # | 작업 | 영향범위 | 예상 난이도 |
|
||||||
|
|---|------|---------|------------|
|
||||||
|
| 6 | **품질검사 정본 결정** (Inspection vs QualityDocument 통합) | 양쪽 서비스 + 프론트 | 상 |
|
||||||
|
| 7 | **출고 시 품질 판정 자동 참조** (can_ship 자동 설정) | ShipmentService + 품질 연동 | 상 |
|
||||||
|
| 8 | **work_order_items.source_order_item_id FK 추가** | 마이그레이션 | 하 |
|
||||||
|
| 9 | **process_id=null 작업지시 생성 방지** | OrderService.createProductionOrder | 하 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 참고: 정상 동작하는 부분
|
||||||
|
|
||||||
|
- ✅ 수주 → 생산지시 생성 (공정별 자동 분류)
|
||||||
|
- ✅ 작업지시 상태 관리 (유효한 상태 전환 규칙)
|
||||||
|
- ✅ 자재 투입 재고 차감 (WorkOrderMaterialInput + StockService)
|
||||||
|
- ✅ 출고 완료 시 재고 차감 (FIFO + 거래 기록)
|
||||||
|
- ✅ 출고 완료 → 수주 상태 SHIPPED 자동 전환
|
||||||
|
- ✅ 매출 자동 생성 (sales_recognition 조건부)
|
||||||
|
- ✅ 수주 상태별 수정/삭제 제한
|
||||||
|
- ✅ 생산지시 되돌리기 (WorkOrder/Item/Result 삭제)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# 문서스냅샷 시스템 (Lazy Snapshot)
|
||||||
|
|
||||||
|
> **작업일**: 2026-03-06 ~ 03-07
|
||||||
|
> **상태**: ✅ 완료
|
||||||
|
> **커밋**: 31f523c8, a1fb0d4f, 8250eaf2, 72a2a3e9, 04f2a8a7
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
문서 저장/조회 시 `rendered_html` 스냅샷을 자동 캡처하여 백엔드에 전송하는 시스템.
|
||||||
|
MNG 측에서 문서 인쇄 시 스냅샷 기반 렌더링에 활용.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
[문서 저장 시]
|
||||||
|
컴포넌트 → contentWrapperRef.innerHTML 캡처
|
||||||
|
→ API 요청에 rendered_html 파라미터 포함 → 백엔드 저장
|
||||||
|
|
||||||
|
[문서 조회 시 — Lazy Snapshot]
|
||||||
|
rendered_html === NULL 감지
|
||||||
|
→ 500ms 대기 (렌더링 완료 대기)
|
||||||
|
→ innerHTML 캡처
|
||||||
|
→ 백그라운드 PATCH 전송 (비차단)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 수동 캡처 (저장 시)
|
||||||
|
|
||||||
|
문서 저장 시 DOM에서 `innerHTML`을 읽어 `rendered_html` 파라미터로 함께 전송.
|
||||||
|
|
||||||
|
- [x] 검사성적서 (InspectionReportModal) — `contentWrapperRef.innerHTML`
|
||||||
|
- [x] 작업일지 (WorkLogModal) — `contentWrapperRef.innerHTML`
|
||||||
|
- [x] 수입검사 (ImportInspectionInputModal) — 오프스크린 렌더링 방식
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/production/WorkOrders/documents/InspectionReportModal.tsx`
|
||||||
|
- `src/components/production/WorkerScreen/WorkLogModal.tsx`
|
||||||
|
- `src/components/material/ReceivingManagement/ImportInspectionInputModal.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Lazy Snapshot (조회 시 자동 캡처)
|
||||||
|
|
||||||
|
`rendered_html`이 NULL인 기존 문서를 조회할 때 자동으로 스냅샷을 캡처하여 백그라운드 저장.
|
||||||
|
|
||||||
|
### 동작 흐름
|
||||||
|
1. 문서 조회 API 응답에서 `snapshot_document_id` 확인
|
||||||
|
2. `rendered_html === NULL` → Lazy Snapshot 트리거
|
||||||
|
3. 500ms 지연 (콘텐츠 렌더링 완료 대기)
|
||||||
|
4. `contentWrapperRef.innerHTML` 캡처
|
||||||
|
5. `patchDocumentSnapshot()` 서버 액션으로 백그라운드 PATCH
|
||||||
|
|
||||||
|
### 특성
|
||||||
|
- **비차단(non-blocking)**: UI에 영향 없이 백그라운드 처리
|
||||||
|
- **1회성**: 스냅샷 저장 후 재조회 시 캡처하지 않음
|
||||||
|
- **readOnly 자동 캡처 제거**: 불필요한 PUT 요청 방지
|
||||||
|
|
||||||
|
### 적용 대상
|
||||||
|
| 문서 | 수동 캡처 | Lazy Snapshot |
|
||||||
|
|------|-----------|---------------|
|
||||||
|
| 검사성적서 | ✅ | ✅ |
|
||||||
|
| 작업일지 | ✅ | ✅ |
|
||||||
|
| 수입검사 | ✅ (오프스크린) | — |
|
||||||
|
| 제품검사 요청서 | ✅ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 오프스크린 렌더링 유틸리티
|
||||||
|
|
||||||
|
폼 HTML이 아닌 실제 문서 렌더링 결과를 캡처하기 위한 유틸리티.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/lib/utils/capture-rendered-html.tsx
|
||||||
|
// 오프스크린 DOM에 문서 컴포넌트를 렌더링하여 innerHTML 추출
|
||||||
|
```
|
||||||
|
|
||||||
|
- [x] 수입검사 모달에서 활용 (폼 캡처 → 문서 캡처 전환)
|
||||||
|
- [x] DocumentViewer 스냅샷 렌더링 지원
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/lib/utils/capture-rendered-html.tsx` (신규)
|
||||||
|
- `src/components/document-system/viewer/DocumentViewer.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 서버 액션
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// patchDocumentSnapshot — 백그라운드 PATCH
|
||||||
|
export async function patchDocumentSnapshot(
|
||||||
|
documentId: string,
|
||||||
|
rendered_html: string
|
||||||
|
): Promise<{ success: boolean }>;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/production/WorkOrders/actions.ts` — `patchDocumentSnapshot`
|
||||||
|
- `src/components/quality/InspectionManagement/fqcActions.ts` — `patchDocumentSnapshot`
|
||||||
@@ -0,0 +1,894 @@
|
|||||||
|
# 동적 멀티테넌트 페이지 시스템 설계
|
||||||
|
|
||||||
|
> 작성일: 2026-03-11
|
||||||
|
> 상태: 초안 (백엔드 논의 필요)
|
||||||
|
> 관련 문서:
|
||||||
|
> - `[VISION-2026-02-19] dynamic-rendering-platform-strategy.md`
|
||||||
|
> - `[PLAN-2026-02-06] multi-tenancy-optimization-roadmap.md`
|
||||||
|
> - `[DESIGN-2026-02-11] dynamic-field-type-extension.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 핵심 목표
|
||||||
|
|
||||||
|
```
|
||||||
|
현재: 테넌트(업종)별 페이지를 하드코딩 → 신규 테넌트마다 개발 필요
|
||||||
|
목표: 백엔드 기준관리에서 설정 → JSON API → 프론트 동적 렌더링
|
||||||
|
결과: 프론트엔드 코드 변경 0줄로 새 테넌트 대응
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 전체 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ 백엔드 어드민 (mng) │
|
||||||
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 기준관리 페이지 │ │
|
||||||
|
│ │ 레이아웃 / 섹션 / 항목 / 속성 등록 │ │
|
||||||
|
│ └───────────────┬───────────────────────────────────┘ │
|
||||||
|
│ │ 저장 │
|
||||||
|
│ ↓ │
|
||||||
|
│ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ DB (테넌트별 페이지 config) │ │
|
||||||
|
│ └───────────────┬───────────────────────────────────┘ │
|
||||||
|
│ │ API │
|
||||||
|
└──────────────────┼──────────────────────────────────────┘
|
||||||
|
↓
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ 프론트엔드 (Next.js) │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────┐ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ 정적 페이지 │ │ 동적 페이지 │ │
|
||||||
|
│ │ - 로그인 │ │ - catch-all route │ │
|
||||||
|
│ │ - 회원가입 │ │ - JSON config → 동적 렌더링 │ │
|
||||||
|
│ │ - 404 등 │ │ - pageType별 렌더러 선택 │ │
|
||||||
|
│ └────────────┘ └─────────────────────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ └──── 공유 컴포넌트 ───────┘ │
|
||||||
|
│ (ui/, molecules/, organisms/) │
|
||||||
|
└──────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 규칙 정의
|
||||||
|
|
||||||
|
### 규칙 1: 기준관리 → 백엔드 어드민
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 현재 | 프론트 `ItemMasterDataManagement` 등에서 기준관리 |
|
||||||
|
| 변경 | 백엔드 어드민(mng) 페이지로 이동 |
|
||||||
|
| 이유 | 프론트 번들 크기 감소, 설정 변경 = 배포 불필요 |
|
||||||
|
| 담당 | 🔵 백엔드 |
|
||||||
|
|
||||||
|
```
|
||||||
|
Before: 프론트 기준관리 UI → 프론트 API 호출 → DB 저장
|
||||||
|
After: 백엔드 어드민 UI → 직접 DB 저장 → API로 config 전달
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 2: 페이지 정보를 JSON API로 제공
|
||||||
|
|
||||||
|
| 항목 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| 방식 | 메뉴 API처럼 페이지 config도 JSON API로 제공 |
|
||||||
|
| 엔드포인트 | `GET /api/v1/page-configs/{slug}` (제안) |
|
||||||
|
| 응답 | 페이지 타입, 레이아웃, 섹션, 필드, 검증규칙, API 매핑 등 |
|
||||||
|
| 담당 | 🔵 백엔드 API 설계 |
|
||||||
|
|
||||||
|
**페이지 config JSON 구조 (제안)**:
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"pageId": "sales-order-list",
|
||||||
|
"pageType": "list", // list | detail | form | dashboard | document
|
||||||
|
"title": "수주 관리",
|
||||||
|
"slug": "sales/order-management",
|
||||||
|
|
||||||
|
// --- 규칙 11: API 엔드포인트 매핑 ---
|
||||||
|
"api": {
|
||||||
|
"list": "/api/v1/orders",
|
||||||
|
"detail": "/api/v1/orders/:id",
|
||||||
|
"create": "/api/v1/orders",
|
||||||
|
"update": "/api/v1/orders/:id",
|
||||||
|
"delete": "/api/v1/orders/:id"
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 규칙 4: 레이아웃 > 섹션 > 항목 > 속성 ---
|
||||||
|
"layout": {
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"sectionId": "filters",
|
||||||
|
"sectionType": "filter",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldId": "status",
|
||||||
|
"type": "select",
|
||||||
|
"label": "상태",
|
||||||
|
"options": [
|
||||||
|
{ "value": "all", "label": "전체" },
|
||||||
|
{ "value": "pending", "label": "대기" },
|
||||||
|
{ "value": "confirmed", "label": "확정" }
|
||||||
|
],
|
||||||
|
"defaultValue": "all"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldId": "dateRange",
|
||||||
|
"type": "dateRange",
|
||||||
|
"label": "기간"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sectionId": "table",
|
||||||
|
"sectionType": "dataTable",
|
||||||
|
"columns": [
|
||||||
|
{ "key": "orderNo", "label": "수주번호", "width": 120 },
|
||||||
|
{ "key": "clientName", "label": "거래처명", "width": 150 },
|
||||||
|
{ "key": "amount", "label": "금액", "type": "currency", "align": "right" },
|
||||||
|
{ "key": "status", "label": "상태", "type": "badge" }
|
||||||
|
],
|
||||||
|
"actions": ["view", "edit", "delete"],
|
||||||
|
"pagination": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 규칙 12: 검증 규칙 ---
|
||||||
|
"validation": {
|
||||||
|
"quantity": { "required": true, "min": 1, "message": "1 이상 입력하세요" },
|
||||||
|
"clientId": { "required": true, "message": "거래처를 선택하세요" }
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- 규칙 13: 필드 간 의존성 ---
|
||||||
|
"dependencies": [
|
||||||
|
{
|
||||||
|
"type": "visibility",
|
||||||
|
"when": { "field": "itemType", "equals": "motor" },
|
||||||
|
"show": ["motorSpec", "voltage"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "computed",
|
||||||
|
"target": "amount",
|
||||||
|
"formula": "quantity * unitPrice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "cascade",
|
||||||
|
"source": "category1",
|
||||||
|
"target": "category2",
|
||||||
|
"api": "/api/v1/categories/:parentId/children"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// --- 규칙 14: 권한 ---
|
||||||
|
"permissions": {
|
||||||
|
"fieldLevel": {
|
||||||
|
"unitPrice": { "view": ["admin", "sales_manager"], "edit": ["admin"] }
|
||||||
|
},
|
||||||
|
"actionLevel": {
|
||||||
|
"delete": ["admin"],
|
||||||
|
"export": ["admin", "sales_manager"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **백엔드 논의 필요**: JSON 구조의 세부 스펙 확정
|
||||||
|
|
||||||
|
#### 2-2. 백엔드 저장 방식: JSONB (확정)
|
||||||
|
|
||||||
|
> ✅ **확정**: 페이지 config는 PostgreSQL **JSONB** 타입으로 저장
|
||||||
|
|
||||||
|
| 항목 | JSON | JSONB (채택) |
|
||||||
|
|------|------|:---:|
|
||||||
|
| 저장 형태 | 텍스트 그대로 | 바이너리 (파싱된 형태) |
|
||||||
|
| 읽기 속도 | 매번 파싱 필요 | 이미 파싱됨 → **빠름** |
|
||||||
|
| 인덱싱 | ❌ 불가 | ✅ **GIN 인덱스 가능** |
|
||||||
|
| 내부 검색 | ❌ 전체 꺼내서 비교 | ✅ **특정 키/값으로 쿼리** |
|
||||||
|
| 부분 수정 | ❌ 전체 교체 | ✅ **특정 키만 업데이트** |
|
||||||
|
|
||||||
|
**JSONB가 필요한 이유 — 우리 시스템과의 연관**:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. 테넌트별 특정 타입 페이지만 조회 (인덱싱)
|
||||||
|
SELECT * FROM page_configs
|
||||||
|
WHERE tenant_id = 282
|
||||||
|
AND config->>'pageType' = 'list';
|
||||||
|
|
||||||
|
-- 2. 특정 필드 타입을 쓰는 페이지 검색 (내부 검색)
|
||||||
|
SELECT * FROM page_configs
|
||||||
|
WHERE config @> '{"layout":{"sections":[{"fields":[{"type":"reference"}]}]}}';
|
||||||
|
|
||||||
|
-- 3. 기준관리에서 섹션 하나만 수정 (부분 수정)
|
||||||
|
UPDATE page_configs
|
||||||
|
SET config = jsonb_set(config, '{layout,sections,0,title}', '"수정된 섹션명"');
|
||||||
|
```
|
||||||
|
|
||||||
|
**JSONB 채택이 config 구조 설계에 미치는 영향**:
|
||||||
|
|
||||||
|
| 영향 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| **구조 단순화** | 하나의 큰 JSONB에 전체 config를 담아도 부분 쿼리/수정 가능 → 테이블 분리 최소화 |
|
||||||
|
| **테넌트 분기** | JSONB 인덱스로 테넌트+pageType 조합 쿼리가 빠름 → 별도 테이블 불필요 |
|
||||||
|
| **기준관리 UI** | 섹션 하나만 수정해도 전체 config를 다시 저장할 필요 없음 → UX 향상 |
|
||||||
|
| **프론트 영향** | **없음** — 프론트는 동일한 JSON을 받아서 렌더링, 저장 방식 무관 |
|
||||||
|
|
||||||
|
```
|
||||||
|
DB 테이블 구조 (제안):
|
||||||
|
|
||||||
|
page_configs
|
||||||
|
├── id (PK)
|
||||||
|
├── tenant_id (FK, 인덱스)
|
||||||
|
├── slug (UNIQUE per tenant, 인덱스)
|
||||||
|
├── config (JSONB) ← 페이지 config 전체
|
||||||
|
├── created_at
|
||||||
|
└── updated_at
|
||||||
|
|
||||||
|
GIN 인덱스: config에 대해 생성 → 내부 검색 고속화
|
||||||
|
복합 인덱스: (tenant_id, slug) → 테넌트별 페이지 조회 최적화
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **백엔드 논의 필요**: JSONB 기반 테이블 설계 세부 확정 (위 제안 구조 검토)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 3: 정적 페이지 vs 동적 페이지 분류
|
||||||
|
|
||||||
|
| 분류 | 정적 페이지 | 동적 페이지 |
|
||||||
|
|------|------------|------------|
|
||||||
|
| 정의 | 테넌트 무관, 고정 UI | 테넌트 config 기반 동적 생성 |
|
||||||
|
| 예시 | 로그인, 회원가입, 404, 500 | 수주관리, 품목관리, 공정관리 등 |
|
||||||
|
| 라우팅 | 기존 파일 기반 라우트 | catch-all `[...slug]` |
|
||||||
|
| 컴포넌트 | 직접 코딩 | JSON → 동적 렌더러 |
|
||||||
|
| 변경 빈도 | 거의 없음 | 테넌트별/설정별 수시 변경 |
|
||||||
|
|
||||||
|
**정적 페이지 목록 (확정)**:
|
||||||
|
|
||||||
|
| 경로 | 페이지 | 이유 |
|
||||||
|
|------|--------|------|
|
||||||
|
| `/login` | 로그인 | 인증 전 접근, 공통 UI |
|
||||||
|
| `/signup` | 회원가입 | 인증 전 접근, 공통 UI |
|
||||||
|
| `/404` | Not Found | 에러 페이지 |
|
||||||
|
| `/500` | Server Error | 에러 페이지 |
|
||||||
|
| `/settings/*` | 설정 | 시스템 설정은 공통 |
|
||||||
|
|
||||||
|
> ⚠️ **논의 필요**: 설정 페이지 중 일부(구독, 결제)도 동적 대상인지?
|
||||||
|
> ⚠️ **논의 필요**: 대시보드는 동적 페이지? 위젯 기반 별도 시스템?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 4: 계층 구조 — 레이아웃 > 섹션 > 항목 > 속성
|
||||||
|
|
||||||
|
```
|
||||||
|
Page (pageType에 의해 렌더러 결정)
|
||||||
|
└─ Layout (전체 레이아웃: single-column, two-column, tabs 등)
|
||||||
|
└─ Section (논리적 그룹: 기본정보, 상세정보, 테이블 등)
|
||||||
|
└─ Field (개별 입력 항목: input, select, date 등)
|
||||||
|
└─ Attribute (필드의 속성: label, placeholder, validation 등)
|
||||||
|
```
|
||||||
|
|
||||||
|
| 계층 | 역할 | 기준관리 등록 항목 | 프론트 컴포넌트 |
|
||||||
|
|------|------|-------------------|----------------|
|
||||||
|
| Layout | 전체 배치 | 레이아웃 타입 선택 | `DynamicPageLayout` |
|
||||||
|
| Section | 논리적 그룹 | 섹션 추가/순서/조건부 표시 | `DynamicSection` |
|
||||||
|
| Field | 개별 항목 | 필드 타입/라벨/기본값 | `DynamicFieldRenderer` (14종) |
|
||||||
|
| Attribute | 필드 속성 | 검증규칙/옵션/의존성 | props로 전달 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 5: 컴포넌트 책임 분리
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 상위: 데이터 처리 컴포넌트 (Layout, Section) │
|
||||||
|
│ - API 호출 / 데이터 가공 │
|
||||||
|
│ - 조건부 표시 로직 │
|
||||||
|
│ - props 전달 / 이벤트 핸들링 │
|
||||||
|
│ - Zustand store 구독 │
|
||||||
|
└──────────────────┬──────────────────────────────┘
|
||||||
|
│ props (순수 데이터)
|
||||||
|
↓
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 하위: 순수 기능 컴포넌트 (Field, Attribute) │
|
||||||
|
│ - UI 렌더링만 담당 │
|
||||||
|
│ - 외부 의존성 없음 │
|
||||||
|
│ - value + onChange 패턴 │
|
||||||
|
│ - 테스트 용이 │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| 구분 | 상위 (Layout/Section) | 하위 (Field/Attribute) |
|
||||||
|
|------|----------------------|----------------------|
|
||||||
|
| 역할 | 데이터 처리, 조건 분기 | 순수 렌더링 |
|
||||||
|
| 상태 | Zustand 구독 | props only |
|
||||||
|
| API | 호출 가능 | 호출 안 함 |
|
||||||
|
| 예시 | `DynamicSection`, `DynamicListPage` | `Input`, `Select`, `DatePicker` |
|
||||||
|
| 테스트 | 통합 테스트 | 단위 테스트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 6: Zustand 기반 상태 관리
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────┐
|
||||||
|
│ pageConfigStore (Zustand) │
|
||||||
|
│ │
|
||||||
|
│ state: │
|
||||||
|
│ configs: Map<slug, PageConfig> │
|
||||||
|
│ currentPage: PageConfig | null │
|
||||||
|
│ loading: boolean │
|
||||||
|
│ │
|
||||||
|
│ actions: │
|
||||||
|
│ fetchPageConfig(slug) → API 호출 + 캐시 │
|
||||||
|
│ invalidateConfig(slug) → 캐시 무효화 │
|
||||||
|
│ subscribeToPage(slug) → 실시간 구독 │
|
||||||
|
└────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 구독
|
||||||
|
↓
|
||||||
|
┌────────────────┐ ┌────────────────┐
|
||||||
|
│ DynamicListPage │ │ DynamicFormPage │ ...
|
||||||
|
└────────────────┘ └────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| 항목 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| Store 위치 | `src/stores/pageConfigStore.ts` (신규) |
|
||||||
|
| 캐시 전략 | 메모리(Zustand) → localStorage → API |
|
||||||
|
| 변경 감지 | 해시 비교 (메뉴 갱신과 동일 방식) |
|
||||||
|
| 테넌트 격리 | 기존 `TenantAwareCache` 패턴 재사용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 7: 테넌트 + 하위 구성요소별 화면 분기
|
||||||
|
|
||||||
|
```
|
||||||
|
테넌트 A (셔터 제조업)
|
||||||
|
├─ 메뉴: 품목관리, 생산관리, 출하관리
|
||||||
|
├─ 품목 폼: 셔터 규격 필드 포함
|
||||||
|
└─ 생산 공정: 셔터 전용 공정 단계
|
||||||
|
|
||||||
|
테넌트 B (건설업)
|
||||||
|
├─ 메뉴: 프로젝트관리, 공사관리, 기성관리
|
||||||
|
├─ 프로젝트 폼: 현장정보 필드 포함
|
||||||
|
└─ 공사 공정: 건설 전용 단계
|
||||||
|
|
||||||
|
같은 테넌트 내에서도:
|
||||||
|
├─ 부서 A → 메뉴 5개, 필드 20개 표시
|
||||||
|
└─ 부서 B → 메뉴 3개, 필드 12개 표시
|
||||||
|
```
|
||||||
|
|
||||||
|
| 분기 기준 | 설명 | 예시 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 테넌트 (company) | 업종별 전체 화면 구성 | 셔터업 vs 건설업 |
|
||||||
|
| 부서 (department) | 같은 테넌트 내 부서별 | 영업팀 vs 생산팀 |
|
||||||
|
| 역할 (role) | 같은 부서 내 역할별 | 관리자 vs 일반 |
|
||||||
|
| 사용자 (user) | 개인 설정 | 즐겨찾기, 컬럼 순서 |
|
||||||
|
|
||||||
|
> ⚠️ **백엔드 논의 필요**: 분기 우선순위 및 상속 정책
|
||||||
|
> (테넌트 설정 → 부서 설정으로 오버라이드 → 사용자 설정으로 오버라이드?)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 8: 정적/동적 컴포넌트 공유
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/
|
||||||
|
├── ui/ ← 공유 (정적+동적 모두 사용)
|
||||||
|
│ ├── Input.tsx
|
||||||
|
│ ├── Select.tsx
|
||||||
|
│ ├── DatePicker.tsx
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── molecules/ ← 공유
|
||||||
|
│ ├── FormField.tsx
|
||||||
|
│ ├── SearchFilter.tsx
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── organisms/ ← 공유
|
||||||
|
│ ├── DataTable.tsx
|
||||||
|
│ ├── MobileCard.tsx
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
├── dynamic/ ← 동적 전용 (신규)
|
||||||
|
│ ├── renderers/
|
||||||
|
│ │ ├── DynamicListPage.tsx
|
||||||
|
│ │ ├── DynamicDetailPage.tsx
|
||||||
|
│ │ ├── DynamicFormPage.tsx
|
||||||
|
│ │ └── DynamicDashboardPage.tsx
|
||||||
|
│ ├── sections/
|
||||||
|
│ │ ├── DynamicSection.tsx
|
||||||
|
│ │ ├── DynamicFilterSection.tsx
|
||||||
|
│ │ └── DynamicTableSection.tsx ← 기존 이동
|
||||||
|
│ ├── fields/
|
||||||
|
│ │ └── DynamicFieldRenderer.tsx ← 기존 이동 (14종)
|
||||||
|
│ └── store/
|
||||||
|
│ └── pageConfigStore.ts
|
||||||
|
│
|
||||||
|
└── static/ ← 정적 전용 (기존 유지)
|
||||||
|
├── auth/LoginPage.tsx
|
||||||
|
└── auth/SignupPage.tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
| 레이어 | 공유 여부 | 예시 |
|
||||||
|
|--------|----------|------|
|
||||||
|
| ui/ | ✅ 100% 공유 | Input, Select, Button |
|
||||||
|
| molecules/ | ✅ 100% 공유 | FormField, StatusBadge |
|
||||||
|
| organisms/ | ✅ 대부분 공유 | DataTable, SearchFilter |
|
||||||
|
| dynamic/renderers/ | ❌ 동적 전용 | DynamicListPage |
|
||||||
|
| 기존 도메인 컴포넌트 | ❌ 정적 전용 (점진적 전환) | OrderSalesDetailEdit |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 9: 페이지 타입 분류 체계
|
||||||
|
|
||||||
|
| pageType | 용도 | 핵심 구성 요소 | 기존 대응 패턴 |
|
||||||
|
|----------|------|--------------|---------------|
|
||||||
|
| `list` | 목록 조회 | 필터 + 테이블 + 페이지네이션 + 액션 | UniversalListPage |
|
||||||
|
| `detail` | 상세 보기 | 읽기전용 섹션 + 수정/삭제 버튼 | IntegratedDetailTemplate |
|
||||||
|
| `form` | 등록/수정 | 입력 섹션 + 저장/취소 | DynamicItemForm (범용화) |
|
||||||
|
| `dashboard` | 대시보드 | 위젯/카드 그리드 | CEODashboard |
|
||||||
|
| `document` | 문서/프린트 | 프린트 레이아웃 + 결재란 | ContractDocument 등 |
|
||||||
|
|
||||||
|
```
|
||||||
|
pageType 결정 흐름:
|
||||||
|
|
||||||
|
API 응답의 pageType 값
|
||||||
|
│
|
||||||
|
├─ "list" → <DynamicListPage config={...} />
|
||||||
|
├─ "detail" → <DynamicDetailPage config={...} />
|
||||||
|
├─ "form" → <DynamicFormPage config={...} />
|
||||||
|
├─ "dashboard" → <DynamicDashboardPage config={...} />
|
||||||
|
├─ "document" → <DynamicDocumentPage config={...} />
|
||||||
|
└─ 미지원 → <FallbackPage /> (에러 표시)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 10: 동적 라우팅 전략
|
||||||
|
|
||||||
|
```
|
||||||
|
src/app/[locale]/(protected)/
|
||||||
|
│
|
||||||
|
├── (static-pages)/ ← 정적 페이지 그룹
|
||||||
|
│ ├── settings/
|
||||||
|
│ └── ...
|
||||||
|
│
|
||||||
|
└── [...slug]/ ← 동적 페이지 catch-all
|
||||||
|
└── page.tsx ← 아래 로직 수행
|
||||||
|
```
|
||||||
|
|
||||||
|
**catch-all page.tsx 동작 흐름**:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. URL에서 slug 추출 (예: ["sales", "order-management"])
|
||||||
|
2. slug로 pageConfigStore에서 config 조회 (캐시 우선)
|
||||||
|
3. 캐시 없으면 → API 호출: GET /api/v1/page-configs/sales/order-management
|
||||||
|
4. config.pageType으로 렌더러 선택
|
||||||
|
5. 렌더러에 config 전달 → 동적 페이지 렌더링
|
||||||
|
```
|
||||||
|
|
||||||
|
| 라우트 우선순위 | 경로 | 설명 |
|
||||||
|
|---------------|------|------|
|
||||||
|
| 1 (최우선) | `/login`, `/signup` | 정적 페이지 (파일 존재) |
|
||||||
|
| 2 | `/settings/*` | 정적 그룹 (파일 존재) |
|
||||||
|
| 3 (폴백) | `/*` (나머지 전부) | catch-all → 동적 처리 |
|
||||||
|
|
||||||
|
> Next.js 라우팅 규칙: 구체적 경로 > catch-all → 충돌 없음
|
||||||
|
|
||||||
|
> ⚠️ **논의 필요**: 기존 정적 페이지를 동적으로 전환 시, 해당 파일 삭제 후 catch-all로 자연스럽게 이관
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 11: API 엔드포인트 동적 매핑
|
||||||
|
|
||||||
|
#### 11-1. API 호출 유형
|
||||||
|
|
||||||
|
| API 유형 | config 키 | 용도 |
|
||||||
|
|---------|----------|------|
|
||||||
|
| `list` | `api.list` | 목록 조회 (GET) |
|
||||||
|
| `detail` | `api.detail` | 상세 조회 (GET) |
|
||||||
|
| `create` | `api.create` | 등록 (POST) |
|
||||||
|
| `update` | `api.update` | 수정 (PUT/PATCH) |
|
||||||
|
| `delete` | `api.delete` | 삭제 (DELETE) |
|
||||||
|
| `export` | `api.export` | 엑셀 다운로드 (GET) |
|
||||||
|
| `custom` | `api.custom[actionName]` | 커스텀 액션 |
|
||||||
|
|
||||||
|
#### 11-2. 백엔드 API 제공 방식 (3가지 방향)
|
||||||
|
|
||||||
|
동적 페이지는 **데이터를 어디서 가져올지도 동적**이어야 합니다.
|
||||||
|
백엔드가 API를 어떤 방식으로 제공하느냐에 따라 3가지 방향이 있습니다.
|
||||||
|
|
||||||
|
| 방향 | 설명 | 장점 | 단점 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| **A. 개별 API** | 페이지마다 전용 API 존재, config에 경로 명시 | 기존 API 재사용, 복잡한 로직 처리 가능 | 새 페이지마다 백엔드 개발 필요 |
|
||||||
|
| **B. 범용 Entity API** | 하나의 엔드포인트가 entityType으로 분기 | 새 페이지 추가 시 백엔드 코드 변경 없음 | 복잡한 비즈니스 로직 처리 어려움 |
|
||||||
|
| **C. 하이브리드 (권장)** | 단순 CRUD는 범용 API, 복잡한 로직은 전용 API | 양쪽 장점 모두 취함 | 두 방식 공존에 따른 관리 비용 |
|
||||||
|
|
||||||
|
**방향 A: 개별 API (config에 경로 포함)**
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"pageType": "list",
|
||||||
|
"slug": "sales/order-management",
|
||||||
|
"api": {
|
||||||
|
"list": "/api/v1/orders",
|
||||||
|
"detail": "/api/v1/orders/:id",
|
||||||
|
"create": "/api/v1/orders",
|
||||||
|
"delete": "/api/v1/orders/:id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
→ 기존에 이미 만들어둔 API를 그대로 config에 연결
|
||||||
|
→ 견적 계산, 세금 처리 등 **비즈니스 로직이 있는 페이지에 적합**
|
||||||
|
|
||||||
|
**방향 B: 범용 Entity API**
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"pageType": "list",
|
||||||
|
"slug": "master/equipment",
|
||||||
|
"entityType": "equipment"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
```
|
||||||
|
// 범용 API 1개로 모든 entity 처리
|
||||||
|
GET /api/v1/entities/{entityType}
|
||||||
|
GET /api/v1/entities/{entityType}/{id}
|
||||||
|
POST /api/v1/entities/{entityType}
|
||||||
|
PUT /api/v1/entities/{entityType}/{id}
|
||||||
|
DELETE /api/v1/entities/{entityType}/{id}
|
||||||
|
```
|
||||||
|
→ 백엔드에서 entityType에 따라 테이블/모델 동적 매핑
|
||||||
|
→ 단순 CRUD(거래처, 설비, 자재 등) **마스터 데이터 페이지에 적합**
|
||||||
|
|
||||||
|
**방향 C: 하이브리드 (권장)**
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ 단순 CRUD 페이지 (거래처, 설비, 자재 등) │
|
||||||
|
│ → 방향 B: 범용 entity API │
|
||||||
|
│ → config에 entityType만 지정 │
|
||||||
|
│ → 새 페이지 추가 시 백엔드 코드 변경 없음 │
|
||||||
|
│ → 동적 시스템의 최대 효과 │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ 비즈니스 로직 페이지 (견적, 생산, 세금계산서) │
|
||||||
|
│ → 방향 A: 전용 API 경로를 config에 명시 │
|
||||||
|
│ → 계산/검증/워크플로우 등 복잡한 로직 처리 │
|
||||||
|
│ → 기존 API 재사용으로 마이그레이션 용이 │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 11-3. 프론트 처리 방식 (어느 방향이든 동일)
|
||||||
|
|
||||||
|
```
|
||||||
|
프론트는 API 제공 방식에 무관하게 동일한 패턴으로 처리:
|
||||||
|
|
||||||
|
1. config에서 API 경로 결정
|
||||||
|
├─ api.list 있으면 → 그 경로 사용 (방향 A)
|
||||||
|
└─ entityType 있으면 → `/api/v1/entities/${entityType}` 생성 (방향 B)
|
||||||
|
↓
|
||||||
|
2. buildApiUrl(경로, params) ← 기존 유틸 재사용
|
||||||
|
↓
|
||||||
|
3. Server Action에서 API 프록시 호출
|
||||||
|
↓
|
||||||
|
4. 응답을 config.columns 기준으로 렌더링
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 프론트 API 경로 결정 유틸 (예시)
|
||||||
|
function resolveApiUrl(config: PageConfig, action: 'list' | 'detail' | 'create' | 'update' | 'delete') {
|
||||||
|
// 방향 A: 전용 API 경로가 있으면 사용
|
||||||
|
if (config.api?.[action]) {
|
||||||
|
return config.api[action];
|
||||||
|
}
|
||||||
|
// 방향 B: entityType으로 범용 API 생성
|
||||||
|
if (config.entityType) {
|
||||||
|
const base = `/api/v1/entities/${config.entityType}`;
|
||||||
|
if (action === 'list' || action === 'create') return base;
|
||||||
|
return `${base}/:id`;
|
||||||
|
}
|
||||||
|
throw new Error(`No API config for action: ${action}`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 11-4. API 응답 구조 통일
|
||||||
|
|
||||||
|
어느 방향이든 **응답 구조는 통일**되어야 프론트가 범용 처리 가능:
|
||||||
|
|
||||||
|
| API 유형 | 응답 구조 |
|
||||||
|
|---------|----------|
|
||||||
|
| list | `{ data: [...], meta: { total, current_page, per_page, last_page } }` |
|
||||||
|
| detail | `{ data: { ... } }` |
|
||||||
|
| create | `{ data: { id, ... }, message: "..." }` |
|
||||||
|
| update | `{ data: { id, ... }, message: "..." }` |
|
||||||
|
| delete | `{ message: "..." }` |
|
||||||
|
|
||||||
|
> ⚠️ **백엔드 논의 필요**:
|
||||||
|
> - 범용 entity API 도입 여부 및 범위
|
||||||
|
> - 기존 API 중 응답 구조가 통일되지 않은 것 정리
|
||||||
|
> - 전용 API와 범용 API의 분류 기준 합의
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 12: 검증(Validation) 규칙
|
||||||
|
|
||||||
|
| 검증 타입 | JSON 표현 | 프론트 변환 |
|
||||||
|
|----------|----------|------------|
|
||||||
|
| 필수값 | `{ "required": true }` | `z.string().min(1)` |
|
||||||
|
| 최솟값 | `{ "min": 1 }` | `z.number().min(1)` |
|
||||||
|
| 최댓값 | `{ "max": 100 }` | `z.number().max(100)` |
|
||||||
|
| 정규식 | `{ "pattern": "^\\d{3}-\\d{2}$" }` | `z.string().regex()` |
|
||||||
|
| 커스텀 메시지 | `{ "message": "올바른 형식이 아닙니다" }` | 에러 메시지 |
|
||||||
|
| 이메일 | `{ "type": "email" }` | `z.string().email()` |
|
||||||
|
| 전화번호 | `{ "type": "phone" }` | `z.string().regex()` |
|
||||||
|
|
||||||
|
```
|
||||||
|
JSON validation config
|
||||||
|
↓ 런타임 변환
|
||||||
|
Zod 스키마 자동 생성
|
||||||
|
↓
|
||||||
|
react-hook-form zodResolver에 주입
|
||||||
|
↓
|
||||||
|
폼 검증 자동 적용
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 13: 필드 간 의존성
|
||||||
|
|
||||||
|
| 의존성 타입 | 설명 | 예시 |
|
||||||
|
|------------|------|------|
|
||||||
|
| `visibility` | 조건부 표시/숨김 | 품목타입=모터 → 전압 필드 표시 |
|
||||||
|
| `computed` | 자동 계산 | 수량 × 단가 = 금액 |
|
||||||
|
| `cascade` | 연쇄 선택 | 대분류 → 중분류 → 소분류 |
|
||||||
|
| `setValue` | 값 자동 설정 | 거래처 선택 → 담당자 자동 입력 |
|
||||||
|
| `disable` | 조건부 비활성화 | 상태=확정 → 수량 수정 불가 |
|
||||||
|
|
||||||
|
```
|
||||||
|
기존 자산 활용:
|
||||||
|
DynamicItemForm의 DisplayCondition → visibility 타입으로 범용화
|
||||||
|
DynamicItemForm의 ComputedField → computed 타입으로 범용화
|
||||||
|
```
|
||||||
|
|
||||||
|
> ✅ **확정**: 복잡한 계산식(견적 할인율 등)은 **백엔드에서 전부 처리**하여 결과만 전달
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 14: 권한 통합
|
||||||
|
|
||||||
|
#### 14-1. 현재 권한 시스템 검증 결과
|
||||||
|
|
||||||
|
✅ **현재 권한 시스템으로 동적 페이지도 컨트롤 가능** (검증 완료)
|
||||||
|
|
||||||
|
현재 권한 시스템이 **메뉴 ID 기반 + URL 패턴 매칭**으로 동작하므로, 페이지가 정적이든 동적이든 해당 URL이 menu 테이블에 등록되어 있으면 권한 관리 페이지에서 동일하게 컨트롤됩니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
현재 (정적 페이지):
|
||||||
|
백엔드 menu 테이블에 URL 등록 → 권한 매트릭스 체크박스 on/off
|
||||||
|
→ PermissionGate가 URL 매칭 → 접근 허용/차단
|
||||||
|
|
||||||
|
동적 페이지도 동일:
|
||||||
|
백엔드 menu 테이블에 동적 페이지 URL(slug) 등록
|
||||||
|
→ 권한 매트릭스에서 동일하게 체크박스 on/off
|
||||||
|
→ PermissionGate가 URL 매칭 → 동일하게 동작
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 14-2. 권한 레벨별 동적 페이지 호환성
|
||||||
|
|
||||||
|
| 권한 레벨 | 현재 지원 | 동적 페이지 호환 | 사용 컴포넌트 |
|
||||||
|
|----------|:---:|:---:|------|
|
||||||
|
| 페이지 접근 (view) | ✅ | ✅ | `PermissionGate` (URL 매칭) |
|
||||||
|
| 생성 (create) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||||
|
| 수정 (update) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||||
|
| 삭제 (delete) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||||
|
| 승인 (approve) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||||
|
| 내보내기 (export) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||||
|
| 관리 (manage) | ✅ | ✅ | `usePermission()` / `PermissionGuard` |
|
||||||
|
| **필드 단위 권한** | ❌ | ❌ | 현재 미지원 → **v2 고려사항** |
|
||||||
|
|
||||||
|
#### 14-3. 권한 적용 흐름
|
||||||
|
|
||||||
|
```
|
||||||
|
권한 적용 흐름 (정적/동적 공통):
|
||||||
|
|
||||||
|
1. 페이지 접근: PermissionGate → URL longest prefix 매칭 → view 권한 확인
|
||||||
|
2. 액션 권한: usePermission() → canCreate/canDelete 등 → 버튼 표시/숨김
|
||||||
|
3. 필드 권한: 현재 미지원 (v2에서 config.permissions.fieldLevel 추가 시 구현)
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **백엔드 논의 필요**: 동적 페이지 URL(slug)을 menu 테이블에 자동 등록하는 방안
|
||||||
|
> (기준관리에서 페이지 생성 시 → menu 테이블에도 자동 연동?)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 15: 캐싱 & 성능 전략
|
||||||
|
|
||||||
|
```
|
||||||
|
요청 흐름:
|
||||||
|
|
||||||
|
1차 캐시 (Zustand 메모리)
|
||||||
|
↓ miss
|
||||||
|
2차 캐시 (localStorage, 테넌트별 격리)
|
||||||
|
↓ miss
|
||||||
|
3차 (API 호출)
|
||||||
|
↓ 응답
|
||||||
|
1차 + 2차 캐시 갱신
|
||||||
|
```
|
||||||
|
|
||||||
|
| 전략 | 방법 | 갱신 주기 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 초기 로드 | 로그인 시 전체 config 프리페치 | 1회 |
|
||||||
|
| 변경 감지 | 해시 비교 (메뉴 갱신과 동일) | 30초~5분 |
|
||||||
|
| 강제 갱신 | 관리자가 기준관리 변경 시 push | 즉시 |
|
||||||
|
| 캐시 무효화 | 테넌트 전환 시 전체 클리어 | 즉시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 16: 비즈니스 로직 처리
|
||||||
|
|
||||||
|
> ✅ **확정**: 복잡한 계산 수식은 **백엔드에서 전부 처리**하여 결과만 전달
|
||||||
|
|
||||||
|
| 로직 복잡도 | 처리 방식 | 예시 |
|
||||||
|
|------------|----------|------|
|
||||||
|
| 단순 계산 | config formula (프론트) | 수량 × 단가 = 금액 |
|
||||||
|
| 복잡한 계산 | **백엔드 API** | 견적 할인, 세금, 재고 검증 등 |
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// config에서 로직 지정
|
||||||
|
{
|
||||||
|
"businessLogic": {
|
||||||
|
// 단순: 프론트 formula (기존 ComputedField 재사용)
|
||||||
|
"amount": { "type": "formula", "expression": "quantity * unitPrice" },
|
||||||
|
|
||||||
|
// 복잡: 백엔드 위임 (확정)
|
||||||
|
"totalDiscount": {
|
||||||
|
"type": "api",
|
||||||
|
"endpoint": "/api/v1/quotes/:id/calculate-discount",
|
||||||
|
"trigger": "onFieldChange",
|
||||||
|
"watchFields": ["quantity", "unitPrice", "discountRate"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
프론트는 단순 사칙연산(ComputedField)만 담당하고, 그 외 모든 비즈니스 로직은 백엔드 API로 위임합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 규칙 17: 점진적 마이그레이션 전략
|
||||||
|
|
||||||
|
| Phase | 범위 | 예상 기간 | 상태 |
|
||||||
|
|-------|------|----------|------|
|
||||||
|
| **Phase 0** | 인프라 구축 | 2-3주 | ⏳ 준비 |
|
||||||
|
| | - catch-all 라우터 | | |
|
||||||
|
| | - pageConfigStore | | |
|
||||||
|
| | - DynamicListPage/FormPage 렌더러 | | |
|
||||||
|
| | - 백엔드 page-config API | | |
|
||||||
|
| **Phase 1** | 신규 테넌트/페이지만 동적 | 2-4주 | ⏳ |
|
||||||
|
| | - 새로 추가되는 페이지는 동적으로 생성 | | |
|
||||||
|
| | - 기존 페이지는 그대로 유지 | | |
|
||||||
|
| **Phase 2** | 단순 CRUD 페이지 전환 | 4-6주 | ⏳ |
|
||||||
|
| | - 리스트+상세만 있는 단순 페이지 | | |
|
||||||
|
| | - 거래처관리, 설비관리 등 | | |
|
||||||
|
| **Phase 3** | 복잡한 비즈니스 페이지 전환 | 6-8주 | ⏳ |
|
||||||
|
| | - 견적, 수주, 생산 등 로직 있는 페이지 | | |
|
||||||
|
| | - 로직 블록 구축 병행 | | |
|
||||||
|
| **Phase 4** | 기존 정적 → 동적 완전 전환 | 지속적 | ⏳ |
|
||||||
|
| | - 남은 하드코딩 페이지 점진적 전환 | | |
|
||||||
|
|
||||||
|
```
|
||||||
|
전환 판단 기준:
|
||||||
|
|
||||||
|
[쉬움] 순수 CRUD (리스트+폼) → Phase 2에서 전환
|
||||||
|
[보통] CRUD + 단순 계산 → Phase 2~3
|
||||||
|
[어려움] 복잡한 비즈니스 로직 → Phase 3
|
||||||
|
[마지막] 문서/프린트, 대시보드 → Phase 4
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 이미 있는 자산 → 재사용 매핑
|
||||||
|
|
||||||
|
| 기존 자산 | 현재 용도 | 동적 시스템에서의 역할 |
|
||||||
|
|----------|----------|---------------------|
|
||||||
|
| DynamicFieldRenderer (14종) | 품목 폼 필드 | → 모든 동적 폼 필드 |
|
||||||
|
| DynamicTableSection | 품목 BOM 테이블 | → 모든 동적 테이블 |
|
||||||
|
| DisplayCondition | 품목 조건부 표시 | → 범용 visibility 규칙 |
|
||||||
|
| ComputedField | 품목 자동 계산 | → 범용 computed 규칙 |
|
||||||
|
| UniversalListPage | 리스트 페이지 템플릿 | → DynamicListPage 기반 |
|
||||||
|
| IntegratedDetailTemplate | 상세 페이지 템플릿 | → DynamicDetailPage 기반 |
|
||||||
|
| TenantAwareCache | 캐시 격리 | → pageConfigStore 캐시 |
|
||||||
|
| menuRefresh (해시 비교) | 메뉴 갱신 | → config 변경 감지 |
|
||||||
|
| buildApiUrl | URL 빌더 | → 동적 API 호출에 재사용 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 논의 현황 정리
|
||||||
|
|
||||||
|
### 확정 사항
|
||||||
|
|
||||||
|
| 항목 | 확정 내용 | 비고 |
|
||||||
|
|------|----------|------|
|
||||||
|
| API 제공 방식 | 하이브리드 (C) — 단순 CRUD는 범용, 복잡 로직은 전용 | 범용 API 세분화 가능성 있음 |
|
||||||
|
| 복잡한 계산 수식 | 백엔드에서 전부 처리, 결과만 전달 | 프론트는 단순 사칙연산만 |
|
||||||
|
| 권한 관리 호환성 | 현재 권한 시스템으로 동적 페이지 컨트롤 가능 | 메뉴 ID + URL 패턴 매칭 방식 |
|
||||||
|
| 기존 동적 필드 재사용 | DynamicFieldRenderer 14종 등 90%+ 재사용 가능 | 기준관리 UI가 mng로 이동해도 렌더링 컴포넌트 유지 |
|
||||||
|
| DB 저장 방식 | PostgreSQL **JSONB** 사용 | 인덱싱/부분수정/내부검색 가능, 프론트 영향 없음 |
|
||||||
|
|
||||||
|
### 협의 필요 사항
|
||||||
|
|
||||||
|
| 항목 | 현재 상태 | 논의 포인트 |
|
||||||
|
|------|----------|------------|
|
||||||
|
| JSON config 세부 구조 | 제안 구조 작성됨 (규칙 2 참조) | 회의에서 세부 항목 결정 후 확정 |
|
||||||
|
| 정적/동적 페이지 분류 | 초안 목록 작성됨 (규칙 3 참조) | 어떤 페이지를 정적으로 남길지 최종 확정 |
|
||||||
|
| 테넌트 하위 분기 정책 | 개념 정리됨 (규칙 7 참조) | 테넌트→부서→역할 오버라이드 정책, config를 최종 결과물로 줄지 프론트가 조합할지 |
|
||||||
|
| 동적 라우팅 전략 | catch-all 방식 제안 (규칙 10 참조) | 기존 정적 페이지와의 공존/전환 전략 |
|
||||||
|
| 범용 entity API 범위 | 하이브리드 방향 합의 | 페이지 렌더링 분기에 따라 범용 API 세분화 가능 |
|
||||||
|
| page-config API 스펙 | 미정 | `GET /api/v1/page-configs/{slug}` 응답 구조 |
|
||||||
|
| 기준관리 어드민 UI | 미정 | mng에서 레이아웃/섹션/필드 등록 화면 설계 |
|
||||||
|
| API 응답 통일 | 미정 | list/detail/create/update/delete 응답 포맷 표준화 |
|
||||||
|
| 캐시 무효화 | 미정 | 기준관리 변경 시 프론트 캐시 갱신 방법 (polling vs push) |
|
||||||
|
| 프리페치 범위 | 미정 | 로그인 시 전체 config vs 페이지 접근 시 개별 로드 |
|
||||||
|
| 검증/의존성 JSON 스펙 | 제안 구조 작성됨 (규칙 12, 13 참조) | 세부 스펙 확정 |
|
||||||
|
| 마이그레이션 순서 | Phase 0~4 제안 (규칙 17 참조) | 어떤 페이지부터 동적 전환할지 |
|
||||||
|
| 동적 페이지 → menu 자동 등록 | 미정 | 기준관리에서 페이지 생성 시 menu 테이블 자동 연동 방안 |
|
||||||
|
| 필드 단위 권한 | 현재 미지원 | v2 고려사항 (필요 시 추가 개발) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 기존 자산 재사용 현황
|
||||||
|
|
||||||
|
### 즉시 재사용 가능 (코드 변경 없음)
|
||||||
|
|
||||||
|
| 자산 | 현재 용도 | 동적 시스템 역할 | 재사용도 |
|
||||||
|
|------|----------|----------------|:---:|
|
||||||
|
| DynamicFieldRenderer (14종) | 품목 폼 필드 | 모든 동적 폼 필드 | 100% |
|
||||||
|
| DynamicTableSection | 품목 BOM 테이블 | 모든 동적 테이블 | 99% |
|
||||||
|
| DisplayCondition (9개 연산자) | 품목 조건부 표시 | 범용 visibility 규칙 | 100% |
|
||||||
|
| ComputedField | 품목 자동 계산 | 범용 단순 계산 | 100% |
|
||||||
|
| Reference Sources 프리셋 | 거래처/품목 등 조회 | 새 source 추가만으로 확장 | 100% |
|
||||||
|
| TenantAwareCache | 캐시 격리 | pageConfigStore 캐시 | 100% |
|
||||||
|
| menuRefresh (해시 비교) | 메뉴 갱신 | config 변경 감지 | 100% |
|
||||||
|
| buildApiUrl | URL 빌더 | 동적 API 호출 | 100% |
|
||||||
|
| PermissionGate / usePermission | 정적 페이지 권한 | 동적 페이지 권한 (동일) | 100% |
|
||||||
|
|
||||||
|
### 범용화 필요 (약간의 리팩토링)
|
||||||
|
|
||||||
|
| 자산 | 변경 사항 |
|
||||||
|
|------|----------|
|
||||||
|
| useDynamicFormState | API URL을 파라미터로 받도록 |
|
||||||
|
| useFormStructure | 품목 전용 API → 범용 API 경로 |
|
||||||
|
| types.ts | `ItemFieldResponse` → `DynamicFieldResponse` 리네이밍 |
|
||||||
|
|
||||||
|
### 신규 개발 필요
|
||||||
|
|
||||||
|
| 자산 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| DynamicListPage | 동적 리스트 페이지 렌더러 (UniversalListPage 기반) |
|
||||||
|
| DynamicDetailPage | 동적 상세 페이지 렌더러 (IntegratedDetailTemplate 기반) |
|
||||||
|
| DynamicDashboardPage | 동적 대시보드 렌더러 |
|
||||||
|
| pageConfigStore | 페이지 config Zustand 스토어 |
|
||||||
|
| catch-all route | `[...slug]/page.tsx` 동적 라우터 |
|
||||||
|
| resolveApiUrl | API 경로 결정 유틸 (개별/범용 분기) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 관련 문서
|
||||||
|
|
||||||
|
| 문서 | 위치 | 내용 |
|
||||||
|
|------|------|------|
|
||||||
|
| 동적 렌더링 플랫폼 비전 | `claudedocs/architecture/[VISION-2026-02-19]` | 전체 비전 및 자산 현황 |
|
||||||
|
| 멀티테넌시 최적화 로드맵 | `claudedocs/architecture/[PLAN-2026-02-06]` | 테넌트 격리/최적화 8 Phase |
|
||||||
|
| 동적 필드 타입 설계 | `claudedocs/architecture/[DESIGN-2026-02-11]` | 4-Level 구조, 14종 필드 |
|
||||||
|
| 동적 필드 구현 현황 | `claudedocs/architecture/[IMPL-2026-02-11]` | Phase 1~3 프론트 구현 완료 |
|
||||||
|
| 백엔드 API 스펙 | `claudedocs/item-master/[API-REQUEST-2026-02-12]` | 동적 필드 타입 백엔드 요청서 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**문서 버전**: 1.2
|
||||||
|
**마지막 업데이트**: 2026-03-11
|
||||||
|
**다음 단계**: 백엔드 회의 → 협의 필요 항목 확정 → v2.0 작성 → `sam-docs/frontend/v2/`에 최종본 등록
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
# [TODO] 유저 개별 설정 DB 이관 계획
|
||||||
|
|
||||||
|
> 현재 localStorage에 저장 중인 유저별 설정을 백엔드 DB로 이관하여 크로스 디바이스 동기화 지원
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 현재 현황: localStorage 기반 유저 설정 목록
|
||||||
|
|
||||||
|
### 🔴 HIGH — 우선 이관 대상
|
||||||
|
|
||||||
|
| 항목 | 저장 키 | 파일 | 유저 분리 | 설명 |
|
||||||
|
|------|---------|------|-----------|------|
|
||||||
|
| 즐겨찾기 | `sam-favorites-{userId}` | `stores/favoritesStore.ts` | ✅ | 메뉴 즐겨찾기 (최대 10개) |
|
||||||
|
| 테이블 컬럼 설정 | `sam-table-columns-{userId}` | `stores/useTableColumnStore.ts` | ✅ | 컬럼 너비, 숨김 여부 (페이지별) |
|
||||||
|
|
||||||
|
### 🟡 MEDIUM — 2차 이관 대상
|
||||||
|
|
||||||
|
| 항목 | 저장 키 | 파일 | 유저 분리 | 설명 |
|
||||||
|
|------|---------|------|-----------|------|
|
||||||
|
| 테마 | `theme` | `stores/themeStore.ts` | ❌ 공용 | light / dark / senior |
|
||||||
|
| 글꼴 크기 | `sam-font-size` | `layouts/AuthenticatedLayout.tsx` | ❌ 공용 | 12~20px (기본 16) |
|
||||||
|
| 사이드바 접힘 | `sam-menu` | `stores/menuStore.ts` | ❌ 공용 | sidebarCollapsed 상태 |
|
||||||
|
| 알림 설정 | `ITEM_VISIBILITY_STORAGE_KEY` | `settings/NotificationSettings/index.tsx` | ❌ 공용 | 알림 카테고리별 표시 여부 |
|
||||||
|
|
||||||
|
### 🟢 LOW — 선택적 이관
|
||||||
|
|
||||||
|
| 항목 | 저장 키 | 파일 | 설명 |
|
||||||
|
|------|---------|------|------|
|
||||||
|
| 팝업 오늘 하루 안 보기 | `popup_dismissed_{id}` | `common/NoticePopupModal.tsx` | 매일 자동 리셋, 임시성 |
|
||||||
|
|
||||||
|
### ❌ 제외 (이관 불필요)
|
||||||
|
|
||||||
|
| 항목 | 이유 |
|
||||||
|
|------|------|
|
||||||
|
| Auth 토큰 (HttpOnly 쿠키) | 이미 서버 관리 |
|
||||||
|
| Auth Store (mes-users, mes-currentUser) | 인증 플로우 전용 |
|
||||||
|
| Master Data 캐시 (sessionStorage) | TTL 기반 캐시, 설정 아님 |
|
||||||
|
| Dashboard Stale 캐시 (sessionStorage) | 세션 캐시 |
|
||||||
|
| Page Builder (page-builder-pages) | 개발 전용 도구 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 백엔드 DB 스키마 (안)
|
||||||
|
|
||||||
|
### user_preferences (통합 설정 테이블)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_preferences (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
theme VARCHAR(20) DEFAULT 'light',
|
||||||
|
font_size TINYINT UNSIGNED DEFAULT 16,
|
||||||
|
sidebar_collapsed BOOLEAN DEFAULT false,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY (tenant_id, user_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### user_favorites (즐겨찾기)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_favorites (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
menu_id VARCHAR(100) NOT NULL,
|
||||||
|
label VARCHAR(255) NOT NULL,
|
||||||
|
icon_name VARCHAR(100),
|
||||||
|
path VARCHAR(500) NOT NULL,
|
||||||
|
display_order TINYINT UNSIGNED DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY (tenant_id, user_id, menu_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### user_table_preferences (테이블 컬럼 설정)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_table_preferences (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
page_id VARCHAR(100) NOT NULL,
|
||||||
|
settings JSON NOT NULL, -- { columnWidths: {...}, hiddenColumns: [...] }
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY (tenant_id, user_id, page_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### user_notification_preferences (알림 설정)
|
||||||
|
```sql
|
||||||
|
CREATE TABLE user_notification_preferences (
|
||||||
|
id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
user_id BIGINT UNSIGNED NOT NULL,
|
||||||
|
settings JSON NOT NULL,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY (tenant_id, user_id)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API 엔드포인트 (안)
|
||||||
|
|
||||||
|
### Phase 1 (즐겨찾기 + 테이블 설정)
|
||||||
|
```
|
||||||
|
GET /api/v1/user/preferences — 전체 설정 조회
|
||||||
|
PATCH /api/v1/user/preferences — 설정 부분 업데이트
|
||||||
|
|
||||||
|
GET /api/v1/user/favorites — 즐겨찾기 목록
|
||||||
|
POST /api/v1/user/favorites — 즐겨찾기 추가
|
||||||
|
DELETE /api/v1/user/favorites/{menuId} — 즐겨찾기 삭제
|
||||||
|
PATCH /api/v1/user/favorites/reorder — 순서 변경
|
||||||
|
|
||||||
|
GET /api/v1/user/table-preferences/{pageId} — 페이지별 컬럼 설정
|
||||||
|
PUT /api/v1/user/table-preferences/{pageId} — 컬럼 설정 저장
|
||||||
|
```
|
||||||
|
|
||||||
|
### Phase 2 (테마/글꼴/사이드바/알림)
|
||||||
|
```
|
||||||
|
GET /api/v1/user/preferences — 위와 동일 (theme, font_size 포함)
|
||||||
|
PATCH /api/v1/user/preferences — 위와 동일
|
||||||
|
|
||||||
|
GET /api/v1/user/notification-preferences
|
||||||
|
PUT /api/v1/user/notification-preferences
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 이관 전략
|
||||||
|
|
||||||
|
### 단계별 마이그레이션
|
||||||
|
1. **DB 테이블 + API 생성** (백엔드)
|
||||||
|
2. **Dual-write 패턴 적용** (프론트)
|
||||||
|
- 저장 시: API 호출 + localStorage 동시 기록
|
||||||
|
- 읽기 시: API 우선 → localStorage 폴백
|
||||||
|
3. **안정화 후 localStorage 제거**
|
||||||
|
|
||||||
|
### 프론트 전환 패턴 (예시)
|
||||||
|
```typescript
|
||||||
|
// createUserStorage → createUserStorageAPI 전환
|
||||||
|
export function createUserStorageAPI(baseKey: string) {
|
||||||
|
return {
|
||||||
|
getItem: async () => {
|
||||||
|
const res = await fetch(`/api/v1/user/${baseKey}`);
|
||||||
|
return res.ok ? res.json() : null;
|
||||||
|
},
|
||||||
|
setItem: async (value: unknown) => {
|
||||||
|
await fetch(`/api/v1/user/${baseKey}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(value),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 우선순위 정리
|
||||||
|
|
||||||
|
| 단계 | 대상 | 이유 |
|
||||||
|
|------|------|------|
|
||||||
|
| Phase 1 | 즐겨찾기, 테이블 컬럼 | 유저별 분리 이미 되어있어 구조 전환 쉬움, 사용 빈도 높음 |
|
||||||
|
| Phase 2 | 테마, 글꼴, 사이드바 | 현재 유저 분리 안 됨 → DB 이관하면서 유저별 적용 |
|
||||||
|
| Phase 3 | 알림 설정 | 기능 안정화 후 진행 |
|
||||||
38
claudedocs/backend/2026-03-02_구현내역.md
Normal file
38
claudedocs/backend/2026-03-02_구현내역.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 2026-03-02 (월) 백엔드 구현 내역
|
||||||
|
|
||||||
|
## 1. `🆕 신규` [roadmap] 중장기 계획 테이블 마이그레이션 추가
|
||||||
|
|
||||||
|
**커밋**: `3ca161e` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
관리자 패널에서 프로젝트 로드맵을 관리할 수 있도록 데이터베이스 테이블이 필요했음.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `admin_roadmap_plans` 테이블 생성 — 계획 마스터 (제목, 카테고리, 상태, Phase, 진행률)
|
||||||
|
- `admin_roadmap_milestones` 테이블 생성 — 마일스톤 관리 (plan_id FK, 상태, 예정일)
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/2026_03_02_000000_create_admin_roadmap_tables.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `🆕 신규` [rd] AI 견적 엔진 테이블 생성 + 모듈 카탈로그 시더
|
||||||
|
|
||||||
|
**커밋**: `abe0460` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
AI 기반 자동 견적 시스템을 위한 데이터 저장 구조 및 초기 모듈 카탈로그 데이터가 필요했음.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `ai_quotation_modules` 테이블 — SAM 모듈 카탈로그 (18개 모듈 정의)
|
||||||
|
- `ai_quotations` 테이블 — AI 견적 요청/결과 저장
|
||||||
|
- `ai_quotation_items` 테이블 — AI 추천 모듈 목록
|
||||||
|
- `AiQuotationModuleSeeder` — customer-pricing 기반 초기 데이터 시딩
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/2026_03_02_100000_create_ai_quotation_tables.php` | 신규 생성 |
|
||||||
|
| `database/seeders/AiQuotationModuleSeeder.php` | 신규 생성 |
|
||||||
197
claudedocs/backend/2026-03-03_구현내역.md
Normal file
197
claudedocs/backend/2026-03-03_구현내역.md
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# 2026-03-03 (화) 백엔드 구현 내역
|
||||||
|
|
||||||
|
## 1. `⚙️ 설정` [ai] Gemini 모델 버전 업그레이드
|
||||||
|
|
||||||
|
**커밋**: `f79d008` | **유형**: chore
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
Google Gemini 모델의 새 버전(2.5-flash)이 출시되어 기존 2.0-flash에서 업그레이드 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `config/services.php` — fallback 기본 모델명 `gemini-2.5-flash`로 변경
|
||||||
|
- `AiReportService.php` — fallback 기본값 동일 변경
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `config/services.php` | 수정 |
|
||||||
|
| `app/Services/AiReportService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `🔧 수정` [deploy] 배포 시 .env 권한 640 보장 추가
|
||||||
|
|
||||||
|
**커밋**: `7e309e4` | **유형**: fix
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
2026-03-03 장애 발생 — vi 편집으로 `.env` 파일 권한이 600으로 변경되어 PHP-FPM이 읽기 실패 → 500 에러. 재발 방지를 위해 배포 파이프라인에 권한 보장 로직 추가.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- Stage/Production Jenkinsfile 배포 스크립트에 `chmod 640 .env` 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `Jenkinsfile` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `🔧 수정` [hr] 사업소득자 임금대장 컬럼 추가
|
||||||
|
|
||||||
|
**커밋**: `b3c7d08` | **유형**: feat (기존 테이블 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
사업소득자(프리랜서)를 시스템 회원이 아닌 직접 입력 대상자로 지원하기 위해 추가 컬럼 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `user_id` nullable 변경 (직접 입력 대상자 지원)
|
||||||
|
- `display_name`, `business_reg_number` 컬럼 추가
|
||||||
|
- 기존 데이터는 earner 프로필에서 자동 채움 마이그레이션
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/..._add_display_name_to_business_income_payments.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `🔧 수정` [ai-quotation] 제조 견적서 마이그레이션 추가
|
||||||
|
|
||||||
|
**커밋**: `da1142a` | **유형**: feat (기존 테이블 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
AI 견적 시스템에서 제조업 견적서를 지원하기 위해 기존 테이블 확장 및 가격표 테이블 신규 생성 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `ai_quotations` 테이블에 `quote_mode`, `quote_number`, `product_category` 컬럼 추가
|
||||||
|
- `ai_quotation_items` 테이블에 `specification`, `unit`, `quantity`, `unit_price`, `total_price`, `item_category`, `floor_code` 컬럼 추가
|
||||||
|
- `ai_quote_price_tables` 테이블 신규 생성
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/..._add_manufacture_fields_to_ai_quotations.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `🔧 수정` [today-issue] 날짜 기반 이전 이슈 조회 기능 추가
|
||||||
|
|
||||||
|
**커밋**: `83a7745` | **유형**: feat (기존 기능 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
오늘의 이슈를 특정 날짜 기준으로 과거 데이터도 조회할 수 있어야 함. 이전에는 현재 날짜 기준만 지원했음.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `TodayIssueController`에 `date` 파라미터(YYYY-MM-DD) 추가
|
||||||
|
- `TodayIssueService.summary()`에 날짜 기반 필터링 로직 구현
|
||||||
|
- 이전 이슈 조회 시 만료(active) 필터 무시하여 과거 데이터 조회 가능
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/TodayIssueController.php` | 수정 |
|
||||||
|
| `app/Services/TodayIssueService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `🔧 수정` [approval] 결재 수신함 날짜 범위 필터 추가
|
||||||
|
|
||||||
|
**커밋**: `b7465be` | **유형**: feat (기존 기능 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
결재 수신함에서 특정 기간의 결재 건만 조회할 수 있도록 날짜 필터 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `InboxIndexRequest`에 `start_date`/`end_date` 검증 룰 추가
|
||||||
|
- `ApprovalService.inbox()`에 `created_at` 날짜 범위 필터 구현
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Requests/Approval/InboxIndexRequest.php` | 수정 |
|
||||||
|
| `app/Services/ApprovalService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `🔧 수정` [daily-report] 자금현황 카드용 필드 추가
|
||||||
|
|
||||||
|
**커밋**: `ad27090` | **유형**: feat (기존 API 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
일일보고서 대시보드에 자금현황 카드를 표시하기 위해 미수금/미지급금/당월 예상 지출 데이터 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- 미수금 잔액(`receivable_balance`) 계산 로직 구현
|
||||||
|
- 미지급금 잔액(`payable_balance`) 계산 로직 구현
|
||||||
|
- 당월 예상 지출(`monthly_expense_total`) 계산 로직 구현
|
||||||
|
- summary API 응답에 자금현황 3개 필드 포함
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/DailyReportService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `🔧 수정` [stock,client,status-board] 날짜 필터 및 조건 보완
|
||||||
|
|
||||||
|
**커밋**: `4244334` | **유형**: feat (기존 기능 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
재고/거래처/현황판 화면에서 날짜 범위 필터가 미지원이었고, 부실채권 현황에 비활성 데이터가 포함되는 이슈.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `StockController/StockService` — 입출고 이력 기반 날짜 범위 필터 추가
|
||||||
|
- `ClientService` — 등록일 기간 필터(`start_date`/`end_date`) 추가
|
||||||
|
- `StatusBoardService` — 부실채권 현황에 `is_active` 조건 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/StockController.php` | 수정 |
|
||||||
|
| `app/Services/StockService.php` | 수정 |
|
||||||
|
| `app/Services/ClientService.php` | 수정 |
|
||||||
|
| `app/Services/StatusBoardService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `🔧 수정` [hr] Leave 모델 확장 + 결재양식 마이그레이션 추가
|
||||||
|
|
||||||
|
**커밋**: `23c6cf6` | **유형**: feat (기존 모델 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
기존 연차/반차만 지원하던 휴가 시스템에 출장, 재택근무, 외근, 조퇴, 지각, 결근 등 근태 유형 확장 필요. 결재 양식(근태신청, 사유서)도 추가.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- Leave 타입 6개 추가: `business_trip`, `remote`, `field_work`, `early_leave`, `late_reason`, `absent_reason`
|
||||||
|
- 그룹 상수: `VACATION_TYPES`, `ATTENDANCE_REQUEST_TYPES`, `REASON_REPORT_TYPES`
|
||||||
|
- `FORM_CODE_MAP` — 유형 → 결재양식코드 매핑
|
||||||
|
- `ATTENDANCE_STATUS_MAP` — 유형 → 근태상태 매핑
|
||||||
|
- 결재양식 2개 추가: `attendance_request`(근태신청), `reason_report`(사유서)
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Models/Tenants/Leave.php` | 수정 |
|
||||||
|
| `database/migrations/..._insert_attendance_approval_forms.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. `🔧 수정` [production] 자재투입 모달 개선
|
||||||
|
|
||||||
|
**커밋**: `fc53789` | **유형**: fix (기존 기능 버그 수정 + 개선)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
자재투입 시 lot 미관리 품목(L-Bar, 보강평철)이 목록에 표시되는 이슈, BOM 그룹키 부재로 동일 자재 구분 불가, 셔터박스 순서가 작업일지와 불일치.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `getMaterialsForItem` — `lot_managed===false` 품목을 자재투입 목록에서 제외
|
||||||
|
- `getMaterialsForItem` — `bom_group_key` 필드 추가 (category+partType 기반 고유키)
|
||||||
|
- `BendingInfoBuilder` — `shutterPartTypes`에서 `top_cover`/`fin_cover` 제거 (중복 방지)
|
||||||
|
- `BendingInfoBuilder` — 셔터박스 루프 순서 파트→길이로 변경
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/Production/BendingInfoBuilder.php` | 수정 |
|
||||||
|
| `app/Services/WorkOrderService.php` | 수정 |
|
||||||
336
claudedocs/backend/2026-03-04_구현내역.md
Normal file
336
claudedocs/backend/2026-03-04_구현내역.md
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
# 2026-03-04 (수) 백엔드 구현 내역
|
||||||
|
|
||||||
|
## 1. `🔧 수정` [inspection] 캘린더 스케줄 조회 API 추가
|
||||||
|
|
||||||
|
**커밋**: `e9fd75f` | **유형**: feat (기존 검사 모듈에 캘린더 API 추가)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
검사 일정을 캘린더 형태로 표시하기 위한 API 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `GET /api/v1/inspections/calendar` 엔드포인트 추가
|
||||||
|
- `year`, `month`, `inspector`, `status` 파라미터 지원
|
||||||
|
- React 프론트엔드 `CalendarItemApi` 형식에 맞춰 응답
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/InspectionController.php` | 수정 |
|
||||||
|
| `app/Services/InspectionService.php` | 수정 |
|
||||||
|
| `routes/api/v1/production.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `🆕 신규` [barobill] 바로빌 연동 API 엔드포인트 추가
|
||||||
|
|
||||||
|
**커밋**: `4f3467c` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
바로빌(전자세금계산서/은행/카드 연동 서비스) API 연동을 위한 백엔드 엔드포인트 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `GET /api/v1/barobill/status` — 연동 현황 조회
|
||||||
|
- `POST /api/v1/barobill/login` — 로그인 정보 등록
|
||||||
|
- `POST /api/v1/barobill/signup` — 회원가입 정보 등록
|
||||||
|
- `GET /api/v1/barobill/bank-service-url` — 은행 서비스 URL
|
||||||
|
- `GET /api/v1/barobill/account-link-url` — 계좌 연동 URL
|
||||||
|
- `GET /api/v1/barobill/card-link-url` — 카드 연동 URL
|
||||||
|
- `GET /api/v1/barobill/certificate-url` — 공인인증서 URL
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/BarobillController.php` | 신규 생성 |
|
||||||
|
| `routes/api/v1/finance.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `🔧 수정` [expense,loan] 대시보드 상세 필터 및 가지급금 카테고리 분류
|
||||||
|
|
||||||
|
**커밋**: `1deeafc` | **유형**: feat (기존 대시보드 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
경비/가지급금 대시보드에서 날짜 범위 필터와 검색 기능이 없었고, 가지급금에 카테고리(카드/경조사/상품권/접대비) 분류 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `ExpectedExpenseController/Service` — dashboardDetail에 `start_date`/`end_date`/`search` 파라미터 추가
|
||||||
|
- `Loan` 모델 — category 상수 및 라벨 정의 (카드/경조사/상품권/접대비)
|
||||||
|
- `LoanService` — dashboard에 `category_breakdown` 집계 추가
|
||||||
|
- 마이그레이션 — loans 테이블 `category` 컬럼 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/ExpectedExpenseController.php` | 수정 |
|
||||||
|
| `app/Models/Tenants/Loan.php` | 수정 |
|
||||||
|
| `app/Services/ExpectedExpenseService.php` | 수정 |
|
||||||
|
| `app/Services/LoanService.php` | 수정 |
|
||||||
|
| `database/migrations/2026_03_04_100000_add_category_to_loans_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `🔧 수정` [models] User 모델 import 누락/오류 수정
|
||||||
|
|
||||||
|
**커밋**: `da04b84` | **유형**: fix (버그 수정)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
Tenants 네임스페이스에서 `User::class`가 `App\Models\Tenants\User`로 잘못 해석되는 문제. Loan, TodayIssue 모델에서 User import 경로 오류.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `Loan.php` — `App\Models\Members\User` import 추가
|
||||||
|
- `TodayIssue.php` — `App\Models\Users\User` → `App\Models\Members\User` 수정
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Models/Tenants/Loan.php` | 수정 |
|
||||||
|
| `app/Models/Tenants/TodayIssue.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `🔧 수정` [cards] 리다이렉트 추가
|
||||||
|
|
||||||
|
**커밋**: `76192fc` | **유형**: fix (하위호환)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
프론트엔드에서 기존 `cards/stats` 경로로 호출하는 코드가 있어 새 경로로 리다이렉트 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `cards/stats` → `card-transactions/dashboard` 리다이렉트 라우트 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `routes/api/v1/finance.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `🔧 수정` [address] 주소 필드 255자 → 500자 확장
|
||||||
|
|
||||||
|
**커밋**: `7cf70db` | **유형**: fix (제한 완화)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
실제 주소 데이터가 255자를 초과하는 경우 발생. DB와 FormRequest 검증 모두 확장 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- DB 마이그레이션 — `clients`, `tenants`, `site_briefings`, `sites` 테이블 address 컬럼 `varchar(500)`
|
||||||
|
- FormRequest 8개 파일 — `max:255` → `max:500` 변경
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Requests/Client/ClientStoreRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Client/ClientUpdateRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/SiteBriefing/StoreSiteBriefingRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/SiteBriefing/UpdateSiteBriefingRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Tenant/TenantStoreRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Tenant/TenantUpdateRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/V1/Site/StoreSiteRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/V1/Site/UpdateSiteRequest.php` | 수정 |
|
||||||
|
| `database/migrations/..._extend_address_columns_to_500.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `🔧 수정` [dashboard] D1.7 기획서 기반 리스크 감지형 서비스 리팩토링
|
||||||
|
|
||||||
|
**커밋**: `e637e3d` | **유형**: feat (기존 대시보드 대규모 리팩토링)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
D1.7 기획서 요구사항에 따라 접대비/복리후생비/매출채권 대시보드를 단순 집계에서 리스크 감지형으로 전환.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `EntertainmentService` — 리스크 감지형 전환 (주말/심야, 기피업종, 고액결제, 증빙미비)
|
||||||
|
- `WelfareService` — 리스크 감지형 전환 (비과세 한도 초과, 사적 사용 의심, 특정인 편중, 항목별 한도 초과)
|
||||||
|
- `ReceivablesService` — summary를 `cards` + `check_points` 구조로 개선 (누적/당월 미수금, Top3 거래처)
|
||||||
|
- `LoanService` — getCategoryBreakdown 전체 대상으로 집계 조건 변경
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/EntertainmentService.php` | 수정 (대규모) |
|
||||||
|
| `app/Services/WelfareService.php` | 수정 (대규모) |
|
||||||
|
| `app/Services/ReceivablesService.php` | 수정 (대규모) |
|
||||||
|
| `app/Services/LoanService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `🔧 수정` [entertainment,welfare] 바로빌 조인 컬럼명 및 심야 시간 파싱 수정
|
||||||
|
|
||||||
|
**커밋**: `f665d3a` | **유형**: fix (버그 수정)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
바로빌 카드거래 테이블 조인 시 컬럼명 불일치 및 심야 판별 함수 오류.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `approval_no` → `approval_num` 컬럼명 수정
|
||||||
|
- `use_time` 심야 판별: `HOUR()` → `SUBSTRING` 문자열 파싱으로 변경
|
||||||
|
- `whereNotNull('bct.use_time')` 조건 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/EntertainmentService.php` | 수정 |
|
||||||
|
| `app/Services/WelfareService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `🆕 신규` [approval] 지출결의서 양식 등록 및 고도화
|
||||||
|
|
||||||
|
**커밋**: `b86af29`, `282bf26` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
전자결재에 지출결의서 양식을 등록하고, HTML body_template 필드로 정형화된 양식 제공.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `approval_forms` 테이블에 `body_template` TEXT 컬럼 추가 (마이그레이션)
|
||||||
|
- 지출결의서(expense) 양식 데이터 등록
|
||||||
|
- 참조 문서 기반으로 정형 양식 HTML 리디자인 — 지출형식/세금계산서 체크박스, 기본정보, 8열 내역 테이블, 합계, 첨부 섹션
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/..._add_body_template_to_approval_forms.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._insert_expense_approval_form.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._update_expense_approval_form_body_template.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. `🆕 신규` [entertainment] 접대비 상세 조회 API + `🔧 수정` 가지급금 날짜 필터
|
||||||
|
|
||||||
|
**커밋**: `66da297`, `a173a5a`, `94b96e2`, `2f3ec13` | **유형**: feat + fix
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
접대비 상세 대시보드(손금한도, 월별추이, 거래내역)가 필요하고, 가지급금 대시보드에도 날짜 필터 지원 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `EntertainmentController/Service` — `getDetail()` 상세 조회 API 신규 (손금한도, 월별추이, 사용자분포, 거래내역, 분기현황)
|
||||||
|
- 수입금액별 추가한도 계산 (세법 기준), 거래건별 리스크 감지
|
||||||
|
- `LoanController/Service` — dashboard에 `start_date`/`end_date` 파라미터 지원 (기존 수정)
|
||||||
|
- `getCategoryBreakdown` SQL alias 충돌 수정
|
||||||
|
- 분기 사용액 조회에 날짜 필터 적용
|
||||||
|
- 라우트: `GET /entertainment/detail` 엔드포인트 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/EntertainmentController.php` | 수정 |
|
||||||
|
| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 |
|
||||||
|
| `app/Services/EntertainmentService.php` | 수정 (대규모) |
|
||||||
|
| `app/Services/LoanService.php` | 수정 |
|
||||||
|
| `routes/api/v1/finance.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. `🆕 신규` [calendar,vat] 캘린더 CRUD 및 부가세 상세 조회 API
|
||||||
|
|
||||||
|
**커밋**: `74a60e0` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
일정 관리를 위한 캘린더 CRUD API와 부가세 상세 조회 대시보드 API 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `CalendarController/Service` — 일정 등록/수정/삭제 API 신규
|
||||||
|
- `VatController/Service` — `getDetail()` 상세 조회 신규 (요약, 참조테이블, 미발행 목록, 신고기간 옵션)
|
||||||
|
- 라우트: `POST/PUT/DELETE /calendar/schedules`, `GET /vat/detail`
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/CalendarController.php` | 신규 생성 |
|
||||||
|
| `app/Http/Controllers/Api/V1/VatController.php` | 신규 생성 |
|
||||||
|
| `app/Services/CalendarService.php` | 신규 생성 |
|
||||||
|
| `app/Services/VatService.php` | 신규 생성 |
|
||||||
|
| `routes/api/v1/finance.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. `🆕 신규` [shipment] 배차정보 다중 행 시스템
|
||||||
|
|
||||||
|
**커밋**: `851862` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
기존 출하 건에 단일 배차정보만 저장 가능했으나, 다중 차량 배차를 지원해야 함.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `shipment_vehicle_dispatches` 테이블 신규 생성 (seq, logistics_company, arrival_datetime, tonnage, vehicle_no, driver_contact, remarks)
|
||||||
|
- `ShipmentVehicleDispatch` 모델 신규
|
||||||
|
- `Shipment` 모델에 `vehicleDispatches()` HasMany 관계 추가
|
||||||
|
- `ShipmentService` — `syncDispatches()` 추가, store/update/delete/show/index에서 연동
|
||||||
|
- FormRequest — Store/Update에 `vehicle_dispatches` 배열 검증 규칙 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 신규 생성 |
|
||||||
|
| `app/Models/Tenants/Shipment.php` | 수정 |
|
||||||
|
| `app/Services/ShipmentService.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Shipment/ShipmentStoreRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Shipment/ShipmentUpdateRequest.php` | 수정 |
|
||||||
|
| `database/migrations/..._create_shipment_vehicle_dispatches_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. `🔧 수정` [production] 자재투입 bom_group_key 개별 저장
|
||||||
|
|
||||||
|
**커밋**: `5ee97c2` | **유형**: fix (기존 기능 보완)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
동일 자재가 다른 BOM 그룹에 속할 때 구분이 안 되는 문제. bom_group_key로 개별 식별 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `work_order_material_inputs` 테이블에 `bom_group_key` 컬럼 추가
|
||||||
|
- 기투입 조회를 `stock_lot_id` + `bom_group_key` 복합키로 변경
|
||||||
|
- `replace` 모드 지원 (기존 삭제 → 재등록)
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Requests/WorkOrder/MaterialInputForItemRequest.php` | 수정 |
|
||||||
|
| `app/Models/Production/WorkOrderMaterialInput.php` | 수정 |
|
||||||
|
| `app/Services/WorkOrderService.php` | 수정 |
|
||||||
|
| `database/migrations/..._bom_group_key_to_work_order_material_inputs.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. `🔧 수정` [production] 절곡 검사 데이터 전체 item 복제 + bending EAV 변환
|
||||||
|
|
||||||
|
**커밋**: `897511c` | **유형**: fix (기존 검사 로직 개선)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
절곡 검사 시 동일 작업지시의 모든 item에 검사 데이터가 복제 저장되어야 하며, products 배열을 bending EAV 레코드로 변환 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `storeItemInspection` — bending/bending_wip 시 동일 작업지시 모든 item에 복제 저장
|
||||||
|
- `transformBendingProductsToRecords` — products 배열 → bending EAV 레코드 변환
|
||||||
|
- `getMaterialInputLots` — 품목코드별 그룹핑으로 변경
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/WorkOrderService.php` | 수정 (대규모) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. `🆕 신규` [outbound] 배차차량 관리 API
|
||||||
|
|
||||||
|
**커밋**: `1a8bb46` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
출고 관련 배차차량을 독립적으로 관리(조회/수정/통계)하는 API 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `VehicleDispatchService` — index(검색/필터/페이지네이션), stats(선불/착불/합계), show, update
|
||||||
|
- `VehicleDispatchController` + `VehicleDispatchUpdateRequest`
|
||||||
|
- options JSON 컬럼 추가 (dispatch_no, status, freight_cost_type, supply_amount, vat, total_amount, writer)
|
||||||
|
- inventory.php에 `vehicle-dispatches` 라우트 4개 등록
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/VehicleDispatchController.php` | 신규 생성 |
|
||||||
|
| `app/Http/Requests/VehicleDispatch/VehicleDispatchUpdateRequest.php` | 신규 생성 |
|
||||||
|
| `app/Services/VehicleDispatchService.php` | 신규 생성 |
|
||||||
|
| `app/Models/Tenants/ShipmentVehicleDispatch.php` | 수정 |
|
||||||
|
| `app/Services/ShipmentService.php` | 수정 |
|
||||||
|
| `database/migrations/..._options_to_shipment_vehicle_dispatches_table.php` | 신규 생성 |
|
||||||
|
| `routes/api/v1/inventory.php` | 수정 |
|
||||||
386
claudedocs/backend/2026-03-05_구현내역.md
Normal file
386
claudedocs/backend/2026-03-05_구현내역.md
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
# 2026-03-05 (목) 백엔드 구현 내역
|
||||||
|
|
||||||
|
## 1. `🔧 수정` [storage] RecordStorageUsage 명령어 수정
|
||||||
|
|
||||||
|
**커밋**: `e0bb19a` | **유형**: fix (버그 수정)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
`Tenant::where('status', 'active')` 하드코딩 사용 중이나 tenants 테이블에 `status` 컬럼이 없고 `tenant_st_code`를 사용함. 모델 스코프 사용으로 수정.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `Tenant::where('status', 'active')` → `Tenant::active()` 스코프 사용
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Console/Commands/RecordStorageUsage.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `🆕 신규` [dashboard-ceo] CEO 대시보드 섹션별 API 및 일일보고서 엑셀
|
||||||
|
|
||||||
|
**커밋**: `e8da2ea`, `f1a3e0f` | **유형**: feat + fix
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
CEO 전용 대시보드에 매출/매입/생산/미출고/시공/근태 등 6개 섹션 데이터를 제공하는 API 및 엑셀 다운로드 기능 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `DashboardCeoController/Service` — 6개 섹션 API 신규 (매출/매입/생산/미출고/시공/근태)
|
||||||
|
- `DailyReportController/Service` — 엑셀 다운로드 API (`GET /daily-report/export`)
|
||||||
|
- 라우트: dashboard 하위 6개 + `daily-report/export` 엔드포인트
|
||||||
|
- 공정명 컬럼 수정 (`p.name` → `p.process_name`)
|
||||||
|
- 근태 부서 조인 수정 (`users.department_id` → `tenant_user_profiles` 경유)
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/DashboardCeoController.php` | 신규 생성 |
|
||||||
|
| `app/Services/DashboardCeoService.php` | 신규 생성 |
|
||||||
|
| `app/Http/Controllers/Api/V1/DailyReportController.php` | 수정 |
|
||||||
|
| `app/Services/DailyReportService.php` | 수정 |
|
||||||
|
| `routes/api/v1/common.php` | 수정 |
|
||||||
|
| `routes/api/v1/finance.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `🔧 수정` [daily-report] 엑셀 내보내기 어음/외상매출채권 현황 및 리팩토링
|
||||||
|
|
||||||
|
**커밋**: `1b2363d`, `fefd129` | **유형**: feat + refactor (기존 엑셀 기능 확장/개선)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
일일보고서 엑셀에 어음/외상매출채권 현황 섹션이 빠져있었고, 엑셀과 화면 데이터가 불일치하는 문제.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `DailyReportExport` — 어음 현황 테이블 + 합계 + 스타일링 추가
|
||||||
|
- `DailyReportService` — exportData를 `dailyAccounts()` 재사용 구조로 리팩토링
|
||||||
|
- 헤더 라벨 전월이월/당월입금/당월출금/잔액으로 수정
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Exports/DailyReportExport.php` | 수정 |
|
||||||
|
| `app/Services/DailyReportService.php` | 수정 (리팩토링) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `🔧 수정` [production] 절곡 검사 FormRequest 검증 누락 수정
|
||||||
|
|
||||||
|
**커밋**: `ef7d9fa` | **유형**: fix (버그 수정)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
`StoreItemInspectionRequest`에 `inspection_data.products` 검증 규칙이 누락되어 `validated()`에서 products 데이터가 제거되는 버그.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `products.*.id`, `bendingStatus`, `lengthMeasured`, `widthMeasured`, `gapPoints` 검증 규칙 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Requests/WorkOrder/StoreItemInspectionRequest.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `🆕 신규` [approval] Document ↔ Approval 브릿지 연동 (Phase 4.2)
|
||||||
|
|
||||||
|
**커밋**: `cd847e0` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
문서(Document) 시스템과 결재(Approval) 시스템을 연동하여, 문서 상신 시 결재가 자동 생성되고 결재 처리 시 문서 상태가 동기화되어야 함.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `Approval` 모델에 `linkable` morphTo 관계 추가
|
||||||
|
- `DocumentService` — 상신 시 Approval 자동 생성 + approval_steps 변환
|
||||||
|
- `ApprovalService` — 승인/반려/회수 시 Document 상태 동기화
|
||||||
|
- `approvals` 테이블에 `linkable_type`, `linkable_id` 컬럼 마이그레이션
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Models/Tenants/Approval.php` | 수정 |
|
||||||
|
| `app/Services/ApprovalService.php` | 수정 |
|
||||||
|
| `app/Services/DocumentService.php` | 수정 |
|
||||||
|
| `database/migrations/..._add_linkable_to_approvals_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `🔧 수정` [process] 공정단계 options 컬럼 추가
|
||||||
|
|
||||||
|
**커밋**: `1f7f45e` | **유형**: feat (기존 테이블 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
공정단계별 검사 설정/범위 등 확장 속성을 저장할 JSON 컬럼 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `ProcessStep` 모델에 `options` JSON 컬럼 추가 (fillable, cast)
|
||||||
|
- Store/UpdateProcessStepRequest에 `inspection_setting`, `inspection_scope` 검증 규칙
|
||||||
|
- `process_steps` 테이블 마이그레이션
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Requests/V1/ProcessStep/StoreProcessStepRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/V1/ProcessStep/UpdateProcessStepRequest.php` | 수정 |
|
||||||
|
| `app/Models/ProcessStep.php` | 수정 |
|
||||||
|
| `database/migrations/..._add_options_to_process_steps_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `🔄 리팩토링` [production] 셔터박스 prefix isStandard 파라미터 제거
|
||||||
|
|
||||||
|
**커밋**: `d4f21f0` | **유형**: refactor
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
CF/CL/CP/CB 품목이 모든 길이에 등록되어 boxSize와 무관하게 적용됨. isStandard 분기가 불필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `resolveShutterBoxPrefix()`에서 `isStandard` 파라미터 및 분기 로직 제거
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/Production/BendingInfoBuilder.php` | 수정 |
|
||||||
|
| `app/Services/Production/PrefixResolver.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `🔧 수정` [production] 자재투입 replace 모드 지원
|
||||||
|
|
||||||
|
**커밋**: `7432fb1` | **유형**: feat (기존 기능 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
자재투입 시 기존 투입 데이터를 교체하는 방식 선택 가능하도록 지원.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `registerMaterialInputForItem`에 `replace` 파라미터 추가
|
||||||
|
- Controller에서 request body의 `replace` 값 전달
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/WorkOrderController.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `🔄 리팩토링` [core] 모델 스코프 적용 규칙 추가
|
||||||
|
|
||||||
|
**커밋**: `9b8cdfa` | **유형**: refactor
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
`where` 하드코딩 대신 모델에 정의된 스코프를 우선 사용하도록 코드 규칙 명시.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `RecordStorageUsage` — where 하드코딩 → `Tenant::active()` 스코프
|
||||||
|
- `CLAUDE.md` — 쿼리 수정 시 모델 스코프 우선 규칙 명시
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `CLAUDE.md` | 수정 |
|
||||||
|
| `app/Console/Commands/RecordStorageUsage.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. `⚙️ 설정` [infra] Slack 알림 채널 분리
|
||||||
|
|
||||||
|
**커밋**: `3d4dd9f` | **유형**: chore
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
배포 알림 채널을 product_infra에서 deploy_api로 분리하여 알림 관리 개선.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- Jenkinsfile Slack 알림 채널 `product_infra` → `deploy_api` 변경
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `Jenkinsfile` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. `🔧 수정` [approval] 결재 테이블 확장 (3건)
|
||||||
|
|
||||||
|
**커밋**: `ac72487`, `558a393`, `ce1f910` | **유형**: feat (기존 테이블 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
결재 시스템에 기안자 읽음 확인, 재상신 횟수, 반려 이력 추적 기능 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `drafter_read_at` 컬럼 — 기안자 완료 결과 확인 타임스탬프 (미읽음 뱃지 지원)
|
||||||
|
- `resubmit_count` 컬럼 — 재상신 횟수 추적
|
||||||
|
- `rejection_history` JSON 컬럼 — 반려 이력 저장
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/..._add_drafter_read_at_to_approvals_table.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._add_resubmit_count_to_approvals_table.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._add_rejection_history_to_approvals_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. `🆕 신규` [rd] CM송 저장 테이블 마이그레이션
|
||||||
|
|
||||||
|
**커밋**: `66d1004` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
AI 생성 CM송(광고 음악) 데이터 저장을 위한 테이블 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `cm_songs` 테이블 생성 — tenant_id, user_id, company_name, industry, lyrics, audio_path, options
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/2026_03_05_170000_create_cm_songs_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. `🆕 신규` [approval] 결재양식 마이그레이션 (3건)
|
||||||
|
|
||||||
|
**커밋**: `f41605c`, `0f25a5d`, `846ced3` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
전자결재에 재직증명서, 경력증명서, 위촉증명서 양식 추가 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `employment_cert` — 재직증명서 양식 등록
|
||||||
|
- `career_cert` — 경력증명서 양식 등록
|
||||||
|
- `appointment_cert` — 위촉증명서 양식 등록
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/2026_03_05_184507_add_employment_cert_form.php` | 신규 생성 |
|
||||||
|
| `database/migrations/2026_03_05_230000_add_career_cert_form.php` | 신규 생성 |
|
||||||
|
| `database/migrations/2026_03_05_234000_add_appointment_cert_form.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. `🔧 수정` [bill,loan] 어음 V8 확장 필드 및 가지급금 상품권 카테고리
|
||||||
|
|
||||||
|
**커밋**: `8c9f2fc` | **유형**: feat (기존 모델 대규모 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
어음 관리에 V8 규격(증권종류, 할인, 배서, 추심, 개서, 부도 등) 54개 필드 지원 필요. 가지급금에 상품권 카테고리 및 상태(보유/사용/폐기) 관리 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `Bill` 모델 — V8 확장 필드 54개 추가, 수취/발행 어음·수표별 세분화된 상태 체계
|
||||||
|
- `BillService` — `assignV8Fields`/`syncInstallments` 헬퍼, instrument_type/medium 필터
|
||||||
|
- `BillInstallment` — type/counterparty 필드 추가
|
||||||
|
- `Loan` 모델 — holding/used/disposed 상태 + metadata(JSON) 필드
|
||||||
|
- `LoanService` — 상품권 카테고리 지원 (summary 상태별 집계, store 기본상태 holding)
|
||||||
|
- FormRequest — V8 확장 필드 검증 규칙
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Models/Tenants/Bill.php` | 수정 (대규모) |
|
||||||
|
| `app/Models/Tenants/BillInstallment.php` | 수정 |
|
||||||
|
| `app/Models/Tenants/Loan.php` | 수정 |
|
||||||
|
| `app/Services/BillService.php` | 수정 |
|
||||||
|
| `app/Services/LoanService.php` | 수정 |
|
||||||
|
| `app/Http/Requests/V1/Bill/StoreBillRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/V1/Bill/UpdateBillRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Loan/LoanStoreRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Loan/LoanUpdateRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Loan/LoanIndexRequest.php` | 수정 |
|
||||||
|
| `app/Http/Controllers/Api/V1/LoanController.php` | 수정 |
|
||||||
|
| `database/migrations/..._add_v8_fields_to_bills_table.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._add_metadata_to_loans_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. `🆕 신규` [loan] 상품권 접대비 자동 연동 + `🔧 수정` 후속 수정 (5건)
|
||||||
|
|
||||||
|
**커밋**: `31d2f08`, `03f86f3`, `652ac3d`, `7fe856f`, `c57e768` | **유형**: feat + fix
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
상품권이 사용+접대비해당일 경우 expense_accounts에 자동으로 접대비 레코드를 생성/삭제해야 함. 관련 집계 및 수정/삭제 정책도 정비.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `ExpenseAccount` — `loan_id` 필드 + `SUB_TYPE_GIFT_CERTIFICATE` 상수 추가
|
||||||
|
- `LoanService` — 상품권 used+접대비해당 시 expense_accounts 자동 upsert/삭제 (🆕)
|
||||||
|
- store()에서도 접대비 자동 연동 호출 (🔧)
|
||||||
|
- `getCategoryBreakdown` — used/disposed 상품권은 가지급금 집계에서 제외 (🔧)
|
||||||
|
- dashboard summary/목록에서도 used/disposed 상품권 제외 (🔧)
|
||||||
|
- `isEditable()`/`isDeletable()` — 상품권이면 상태 무관하게 허용 (🔧)
|
||||||
|
- 접대비 연동 시 `receipt_no`에 시리얼번호 매핑 (🔧)
|
||||||
|
- `expense_accounts`에 `loan_id` 컬럼 마이그레이션
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Models/Tenants/ExpenseAccount.php` | 수정 |
|
||||||
|
| `app/Models/Tenants/Loan.php` | 수정 |
|
||||||
|
| `app/Services/LoanService.php` | 수정 (다회) |
|
||||||
|
| `database/migrations/..._add_loan_id_to_expense_accounts_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. `🆕 신규` [생산지시] 전용 API 엔드포인트 신규 생성 + `🔧 수정` 후속 수정 (4건)
|
||||||
|
|
||||||
|
**커밋**: `2df8ecf`, `59d13ee`, `38c2402`, `0aa0a85` | **유형**: feat + fix
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
수주 기반 생산지시 전용 API가 없어 프론트엔드에서 여러 API를 조합해야 했음. 전용 엔드포인트로 통합.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `ProductionOrderService` — 목록(index), 통계(stats), 상세(show) 구현 (🆕)
|
||||||
|
- Order 기반 생산지시 대상 필터링 (IN_PROGRESS~SHIPPED)
|
||||||
|
- `workOrderProgress` 가공 필드, `production_ordered_at` 첫 WO 기반
|
||||||
|
- BOM 공정 분류 추출 (order_nodes.options.bom_result)
|
||||||
|
- `ProductionOrderController` + `ProductionOrderIndexRequest` + Swagger 문서 (🆕)
|
||||||
|
- 날짜 포맷 Y-m-d 변환, `withCount('nodes')` 개소수 추가 (🔧)
|
||||||
|
- 자재투입 시 WO 자동 상태 전환 (`autoStartWorkOrderOnMaterialInput`) (🆕)
|
||||||
|
- `process_id=null`인 구매품/서비스 WO 제외 (🔧)
|
||||||
|
- `extractBomProcessGroups` BOM 파싱 수정 (🔧)
|
||||||
|
- 재고생산 보조 공정을 일반 워크플로우에서 분리 (`is_auxiliary` 플래그) (🆕)
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/ProductionOrderController.php` | 신규 생성 |
|
||||||
|
| `app/Http/Requests/ProductionOrder/ProductionOrderIndexRequest.php` | 신규 생성 |
|
||||||
|
| `app/Services/ProductionOrderService.php` | 신규 생성 + 수정 |
|
||||||
|
| `app/Swagger/v1/ProductionOrderApi.php` | 신규 생성 |
|
||||||
|
| `app/Services/WorkOrderService.php` | 수정 |
|
||||||
|
| `app/Services/OrderService.php` | 수정 |
|
||||||
|
| `routes/api/v1/production.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 17. `🆕 신규` [품질관리] 백엔드 API 구현 + `🔧 수정` 후속 수정 (3건)
|
||||||
|
|
||||||
|
**커밋**: `a6e29bc`, `3600c7b`, `0f26ea5` | **유형**: feat + fix
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
품질관리서(제품검사 요청서) 및 실적신고 관리를 위한 백엔드 API 전체 구현.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- 품질관리서(quality_documents) CRUD API 14개 엔드포인트 (🆕)
|
||||||
|
- 실적신고(performance_reports) 관리 API 6개 엔드포인트 (🆕)
|
||||||
|
- DB 마이그레이션 4개 테이블 (🆕)
|
||||||
|
- 모델 4개 + 서비스 2개 + 컨트롤러 2개 + FormRequest 4개 (🆕)
|
||||||
|
- 납품일 Y-m-d 포맷 변환, 개소 수 order_nodes 루트 노드 기준 변경 (🔧)
|
||||||
|
- 수주선택 API에 `client_name` 필드 추가 (🔧)
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/QualityDocumentController.php` | 신규 생성 |
|
||||||
|
| `app/Http/Controllers/Api/V1/PerformanceReportController.php` | 신규 생성 |
|
||||||
|
| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 신규 생성 |
|
||||||
|
| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 신규 생성 |
|
||||||
|
| `app/Http/Requests/Quality/PerformanceReportConfirmRequest.php` | 신규 생성 |
|
||||||
|
| `app/Http/Requests/Quality/PerformanceReportMemoRequest.php` | 신규 생성 |
|
||||||
|
| `app/Models/Qualitys/QualityDocument.php` | 신규 생성 |
|
||||||
|
| `app/Models/Qualitys/QualityDocumentOrder.php` | 신규 생성 |
|
||||||
|
| `app/Models/Qualitys/QualityDocumentLocation.php` | 신규 생성 |
|
||||||
|
| `app/Models/Qualitys/PerformanceReport.php` | 신규 생성 |
|
||||||
|
| `app/Services/QualityDocumentService.php` | 신규 생성 + 수정 |
|
||||||
|
| `app/Services/PerformanceReportService.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._create_quality_documents_table.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._create_quality_document_orders_table.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._create_quality_document_locations_table.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._create_performance_reports_table.php` | 신규 생성 |
|
||||||
|
| `routes/api/v1/quality.php` | 신규 생성 |
|
||||||
287
claudedocs/backend/2026-03-06_구현내역.md
Normal file
287
claudedocs/backend/2026-03-06_구현내역.md
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
# 2026-03-06 (금) 백엔드 구현 내역
|
||||||
|
|
||||||
|
## 1. `🔧 수정` [생산지시] 보조 공정 WO 카운트 제외
|
||||||
|
|
||||||
|
**커밋**: `a845f52` | **유형**: fix (기존 기능 보완)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
목록 조회 시 `work_orders_count`에 보조 공정(재고생산) WO가 포함되어 공정 진행률이 부정확.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `withCount`에서 `is_auxiliary` WO 제외 조건 추가
|
||||||
|
- `whereNotNull(process_id)` + `options->is_auxiliary` 조건 적용
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/ProductionOrderService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `🔧 수정` [loan] 상품권 summary에 접대비 집계 추가
|
||||||
|
|
||||||
|
**커밋**: `a7973bb` | **유형**: feat (기존 API 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
상품권 대시보드에서 접대비로 전환된 건수/금액을 별도로 표시해야 함.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `expense_accounts` 테이블에서 접대비(상품권) 건수/금액 조회
|
||||||
|
- `entertainment_count`, `entertainment_amount` 응답 필드 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/LoanService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. `🔧 수정` [receivables] 상위 거래처 집계 soft delete 제외
|
||||||
|
|
||||||
|
**커밋**: `be9c1ba` | **유형**: fix (버그 수정)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
매출채권 상위 거래처 집계 쿼리에서 soft delete된 레코드가 포함되어 금액이 부풀려지는 이슈.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- orders, deposits, bills 서브쿼리에 `whereNull('deleted_at')` 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/ReceivablesService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. `🆕 신규` [finance] 계정과목 및 일반전표 API 추가
|
||||||
|
|
||||||
|
**커밋**: `12d172e` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
회계 시스템의 핵심인 계정과목 관리 및 일반전표(입금/출금/수동전표 통합 목록) API 신규 구현.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `AccountCode` 모델/서비스/컨트롤러 — 계정과목 CRUD
|
||||||
|
- `JournalEntry`, `JournalEntryLine` 모델 — 전표/전표 분개 모델
|
||||||
|
- `GeneralJournalEntryService` — 입금/출금/수동전표 UNION 통합 목록, 수동전표 CRUD
|
||||||
|
- `GeneralJournalEntryController` + FormRequest 검증 클래스
|
||||||
|
- finance 라우트 등록, i18n 메시지 키 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 신규 생성 |
|
||||||
|
| `app/Http/Controllers/Api/V1/GeneralJournalEntryController.php` | 신규 생성 |
|
||||||
|
| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 신규 생성 |
|
||||||
|
| `app/Http/Requests/V1/GeneralJournalEntry/StoreManualJournalRequest.php` | 신규 생성 |
|
||||||
|
| `app/Http/Requests/V1/GeneralJournalEntry/UpdateJournalRequest.php` | 신규 생성 |
|
||||||
|
| `app/Models/Tenants/AccountCode.php` | 신규 생성 |
|
||||||
|
| `app/Models/Tenants/JournalEntry.php` | 신규 생성 |
|
||||||
|
| `app/Models/Tenants/JournalEntryLine.php` | 신규 생성 |
|
||||||
|
| `app/Services/AccountCodeService.php` | 신규 생성 |
|
||||||
|
| `app/Services/GeneralJournalEntryService.php` | 신규 생성 |
|
||||||
|
| `lang/ko/error.php` | 수정 |
|
||||||
|
| `lang/ko/message.php` | 수정 |
|
||||||
|
| `routes/api/v1/finance.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `🔧 수정` [finance] 일반전표 source 필드 및 페이지네이션 수정
|
||||||
|
|
||||||
|
**커밋**: `816c25a` | **유형**: fix (신규 기능 후속 수정)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
입금/출금 조회 시 source가 CASE WHEN으로 불필요하게 분기되었고, 페이지네이션 응답 구조가 프론트엔드 기대와 불일치.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- deposits/withdrawals 조회 시 source를 항상 `'linked'`로 고정
|
||||||
|
- 페이지네이션 meta 래핑 제거 → 플랫 구조로 변경
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/GeneralJournalEntryService.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `🆕 신규` [menu] 즐겨찾기 테이블 마이그레이션
|
||||||
|
|
||||||
|
**커밋**: `a67c5d9` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
사용자별 메뉴 즐겨찾기 기능을 위한 데이터 테이블 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `menu_favorites` 테이블 — tenant_id, user_id, menu_id, sort_order
|
||||||
|
- unique 제약: (tenant_id, user_id, menu_id)
|
||||||
|
- FK cascade delete: users, menus
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/2026_03_06_143037_create_menu_favorites_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. `🔧 수정` [departments] options JSON 컬럼 추가
|
||||||
|
|
||||||
|
**커밋**: `56e7164` | **유형**: feat (기존 테이블 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
조직도 숨기기 등 부서별 확장 속성을 저장할 JSON 컬럼 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `departments` 테이블에 `options` JSON 컬럼 추가
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/..._add_options_to_departments_table.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. `🆕 신규` [approval] 결재양식 마이그레이션 (6건)
|
||||||
|
|
||||||
|
**커밋**: `58fedb0`, `eb28b57`, `c5a0115`, `9d4143a`, `449fce1`, `96def0d` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
전자결재 양식 확대 — 사용인감계, 사직서, 위임장, 이사회의사록, 견적서, 공문서 양식 추가.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `seal_usage` — 사용인감계 양식
|
||||||
|
- `resignation` — 사직서 양식
|
||||||
|
- `delegation` — 위임장 양식
|
||||||
|
- `board_minutes` — 이사회의사록 양식
|
||||||
|
- `quotation` — 견적서 양식
|
||||||
|
- `official_letter` — 공문서 양식
|
||||||
|
- 전체 테넌트에 자동 등록
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/2026_03_06_100000_add_resignation_form.php` | 신규 생성 |
|
||||||
|
| `database/migrations/2026_03_06_210000_add_seal_usage_form.php` | 신규 생성 |
|
||||||
|
| `database/migrations/2026_03_06_230000_add_delegation_form.php` | 신규 생성 |
|
||||||
|
| `database/migrations/2026_03_06_233000_add_board_minutes_form.php` | 신규 생성 |
|
||||||
|
| `database/migrations/2026_03_06_235000_add_quotation_form.php` | 신규 생성 |
|
||||||
|
| `database/migrations/2026_03_07_000000_add_official_letter_form.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. `🆕 신규` [database] 경조사비 관리 테이블 + 메뉴 추가
|
||||||
|
|
||||||
|
**커밋**: `0ea5fa5`, `22160e5` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
거래처 경조사비 관리대장 기능 신규 도입. 데이터 테이블 및 사이드바 메뉴 추가 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `condolence_expenses` 테이블 — 경조사일자, 지출일자, 거래처명, 내역, 구분(축의/부조), 부조금, 선물, 총금액
|
||||||
|
- 각 테넌트의 부가세관리 메뉴 하위에 경조사비관리 메뉴 자동 추가 (중복 방지)
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/..._create_condolence_expenses_table.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._add_condolence_expenses_menu.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. `🆕 신규` [문서스냅샷] rendered_html 저장 지원 + Lazy Snapshot API
|
||||||
|
|
||||||
|
**커밋**: `293330c`, `5ebf940`, `c5d5b5d` | **유형**: feat + fix
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
문서의 렌더링된 HTML을 스냅샷으로 저장하여 PDF 변환/인쇄 등에 활용. 편집 권한 없이도 스냅샷 갱신 가능한 Lazy Snapshot API 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `Document` 모델 $fillable에 `rendered_html` 추가 (🔧)
|
||||||
|
- `DocumentService` create/update에서 rendered_html 저장 (🔧)
|
||||||
|
- Store/Update/UpsertRequest에 `rendered_html` 검증 추가 (🔧)
|
||||||
|
- `WorkOrderService` 검사문서/작업일지 생성 시 rendered_html 전달 (🔧)
|
||||||
|
- `PATCH /documents/{id}/snapshot` — canEdit 체크 없이 rendered_html만 업데이트 (🆕)
|
||||||
|
- `resolveInspectionDocument()`에 `snapshot_document_id` 반환 (🆕)
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Models/Documents/Document.php` | 수정 |
|
||||||
|
| `app/Services/DocumentService.php` | 수정 |
|
||||||
|
| `app/Services/WorkOrderService.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Document/StoreRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Document/UpdateRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Document/UpsertRequest.php` | 수정 |
|
||||||
|
| `app/Http/Controllers/Api/V1/Documents/DocumentController.php` | 수정 |
|
||||||
|
| `routes/api/v1/documents.php` | 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. `🔧 수정` [품질관리] order_ids 영속성 + location 데이터 저장
|
||||||
|
|
||||||
|
**커밋**: `f2eede6` | **유형**: feat (기존 API 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
품질관리서에 수주 연결 및 개소별 검사 데이터(시공규격, 변경사유, 검사결과)를 저장해야 함.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- StoreRequest/UpdateRequest에 `order_ids`, `locations` 검증 추가
|
||||||
|
- `QualityDocumentLocation`에 `inspection_data`(JSON) fillable/cast 추가
|
||||||
|
- store()에 `syncOrders` 연동, update()에 `syncOrders` + `updateLocations` 연동
|
||||||
|
- `inspection_data` 컬럼 추가 마이그레이션
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Requests/Quality/QualityDocumentStoreRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/Quality/QualityDocumentUpdateRequest.php` | 수정 |
|
||||||
|
| `app/Models/Qualitys/QualityDocumentLocation.php` | 수정 |
|
||||||
|
| `app/Services/QualityDocumentService.php` | 수정 (대규모) |
|
||||||
|
| `database/migrations/..._inspection_data_to_quality_document_locations.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. `🆕 신규` 제품검사 요청서 Document(EAV) 자동생성 및 동기화
|
||||||
|
|
||||||
|
**커밋**: `2231c9a` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
품질관리서 생성/수정/수주연결 시 제품검사 요청서 Document를 EAV 방식으로 자동 생성하고 동기화해야 함.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `document_template_sections`에 `description` 컬럼 추가
|
||||||
|
- `QualityDocumentService`에 `syncRequestDocument()` 메서드 추가
|
||||||
|
- 기본필드, 섹션 데이터, 사전고지 테이블 EAV 자동매핑
|
||||||
|
- `rendered_html` 초기화 (데이터 변경 시 재캡처 트리거)
|
||||||
|
- `transformToFrontend`에 `request_document_id` 포함
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Models/Documents/DocumentTemplateSection.php` | 수정 |
|
||||||
|
| `app/Services/QualityDocumentService.php` | 수정 (대규모) |
|
||||||
|
| `database/migrations/..._add_description_to_document_template_sections.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. `⚙️ 설정` [API] logging, docs, seeder 등 부수 정리
|
||||||
|
|
||||||
|
**커밋**: `ff85530` | **유형**: chore
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
여러 파일의 경로, 설정, 문서 등 소소한 정리 작업.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `LOGICAL_RELATIONSHIPS.md` 보완 (최신 모델 관계 반영)
|
||||||
|
- `Legacy5130Calculator` 수정
|
||||||
|
- `logging.php` 설정 추가
|
||||||
|
- `KyungdongItemSeeder` 수정
|
||||||
|
- docs 문서 경로 수정
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `LOGICAL_RELATIONSHIPS.md` | 수정 |
|
||||||
|
| `app/Helpers/Legacy5130Calculator.php` | 수정 |
|
||||||
|
| `config/logging.php` | 수정 |
|
||||||
|
| `database/seeders/Kyungdong/KyungdongItemSeeder.php` | 수정 |
|
||||||
|
| `docs/INDEX.md` | 수정 |
|
||||||
40
claudedocs/backend/2026-03-07_구현내역.md
Normal file
40
claudedocs/backend/2026-03-07_구현내역.md
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# 2026-03-07 (토) 백엔드 구현 내역
|
||||||
|
|
||||||
|
## 1. `🆕 신규` [approval] 연차사용촉진 통지서 1차/2차 양식 마이그레이션
|
||||||
|
|
||||||
|
**커밋**: `ad93743` | **유형**: feat
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
근로기준법에 따른 연차사용촉진 통지서(1차/2차) 양식을 전자결재 시스템에 등록 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `leave_promotion_1st` — 연차사용촉진 통지서 (1차) 양식, hr 카테고리
|
||||||
|
- `leave_promotion_2nd` — 연차사용촉진 통지서 (2차) 양식, hr 카테고리
|
||||||
|
- 전체 테넌트에 자동 등록
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `database/migrations/2026_03_07_100000_add_leave_promotion_forms.php` | 신규 생성 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. `🔧 수정` [품질검사] 수주 선택 필터링 + 개소 상세 + 검사 상태 개선
|
||||||
|
|
||||||
|
**커밋**: `3ac64d5` | **유형**: feat (기존 API 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
품질관리서 작성 시 수주 선택 API에 거래처/품목 필터가 없고, 개소별 상세 데이터 부족. 검사 상태 판별 로직도 개선 필요.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
- `availableOrders` — `client_id`/`item_id` 필터 파라미터 지원
|
||||||
|
- 응답에 `client_id`, `client_name`, `item_id`, `item_name`, `locations`(개소 상세) 추가
|
||||||
|
- `show` — 개소별 데이터에 거래처/모델 정보 포함
|
||||||
|
- `DocumentService` — `fqcStatus`를 rootNodes 기반으로 변경
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Services/QualityDocumentService.php` | 수정 |
|
||||||
|
| `app/Services/DocumentService.php` | 수정 |
|
||||||
|
| `LOGICAL_RELATIONSHIPS.md` | 수정 |
|
||||||
47
claudedocs/backend/2026-03-08_구현내역.md
Normal file
47
claudedocs/backend/2026-03-08_구현내역.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 2026-03-08 (일) 백엔드 구현 내역
|
||||||
|
|
||||||
|
## 1. `🔧 수정` [finance] 계정과목 확장 및 전표 연동 시스템 구현
|
||||||
|
|
||||||
|
**커밋**: `0044779` | **유형**: feat (3/6 신규 기능 대규모 확장)
|
||||||
|
|
||||||
|
### 배경
|
||||||
|
3/6에 추가한 계정과목/일반전표 기본 API를 확장하여 기본 계정과목 시딩, 전표 자동 연동(카드거래/세금계산서), 계정과목 업데이트 기능 구현.
|
||||||
|
|
||||||
|
### 구현 내용
|
||||||
|
|
||||||
|
#### 계정과목 확장 (🔧 기존 확장)
|
||||||
|
- `AccountCode` 모델 확장 — 관계, 스코프, 헬퍼 추가
|
||||||
|
- `AccountCodeService` 확장 — 업데이트, 트리 조회, 기본 계정과목 시딩 로직
|
||||||
|
- `UpdateAccountSubjectRequest` 신규 — 업데이트 검증 규칙
|
||||||
|
- `StoreAccountSubjectRequest` — 추가 검증 규칙 보강
|
||||||
|
|
||||||
|
#### 전표 자동 연동 (🆕 신규)
|
||||||
|
- `JournalSyncService` 신규 — 카드거래/세금계산서 → 전표 자동 생성 서비스
|
||||||
|
- `SyncsExpenseAccounts` 트레이트 — 경비계정 동기화 공통 로직
|
||||||
|
- `CardTransactionController` 확장 — 전표 연동 엔드포인트 추가
|
||||||
|
- `TaxInvoiceController` 확장 — 전표 연동 엔드포인트 추가
|
||||||
|
|
||||||
|
#### 데이터베이스 (🆕 신규)
|
||||||
|
- `expense_accounts` 테이블에 전표 연결 컬럼 마이그레이션 (journal_entry_id 등)
|
||||||
|
- `account_codes` 테이블 확장 마이그레이션 (추가 속성 컬럼)
|
||||||
|
- 전체 테넌트 기본 계정과목 시딩 마이그레이션
|
||||||
|
|
||||||
|
### 변경 파일
|
||||||
|
| 파일 | 작업 |
|
||||||
|
|------|------|
|
||||||
|
| `app/Http/Controllers/Api/V1/AccountSubjectController.php` | 수정 |
|
||||||
|
| `app/Http/Controllers/Api/V1/CardTransactionController.php` | 수정 (대규모) |
|
||||||
|
| `app/Http/Controllers/Api/V1/TaxInvoiceController.php` | 수정 (대규모) |
|
||||||
|
| `app/Http/Requests/V1/AccountSubject/StoreAccountSubjectRequest.php` | 수정 |
|
||||||
|
| `app/Http/Requests/V1/AccountSubject/UpdateAccountSubjectRequest.php` | 신규 생성 |
|
||||||
|
| `app/Models/Tenants/AccountCode.php` | 수정 |
|
||||||
|
| `app/Models/Tenants/ExpenseAccount.php` | 수정 |
|
||||||
|
| `app/Models/Tenants/JournalEntry.php` | 수정 |
|
||||||
|
| `app/Services/AccountCodeService.php` | 수정 (대규모) |
|
||||||
|
| `app/Services/GeneralJournalEntryService.php` | 수정 |
|
||||||
|
| `app/Services/JournalSyncService.php` | 신규 생성 |
|
||||||
|
| `app/Traits/SyncsExpenseAccounts.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._add_journal_link_to_expense_accounts_table.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._enhance_account_codes_table.php` | 신규 생성 |
|
||||||
|
| `database/migrations/..._seed_default_account_codes_for_all_tenants.php` | 신규 생성 |
|
||||||
|
| `routes/api/v1/finance.php` | 수정 |
|
||||||
72
claudedocs/backend/_index.md
Normal file
72
claudedocs/backend/_index.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# SAM API 백엔드 구현 내역서
|
||||||
|
|
||||||
|
## 2026년 3월 1주차 (3/2 ~ 3/8)
|
||||||
|
|
||||||
|
총 **83개 커밋**, 7일간 구현 내역
|
||||||
|
|
||||||
|
### 태그 범례
|
||||||
|
| 태그 | 의미 |
|
||||||
|
|------|------|
|
||||||
|
| `🆕 신규` | 새로운 기능/API/테이블 생성 |
|
||||||
|
| `🔧 수정` | 기존 기능 버그 수정, 확장, 보완 |
|
||||||
|
| `🔄 리팩토링` | 기능 변경 없이 코드 구조 개선 |
|
||||||
|
| `⚙️ 설정` | 환경 설정, 인프라, 문서 정리 |
|
||||||
|
|
||||||
|
### 날짜별 문서
|
||||||
|
|
||||||
|
| 날짜 | 파일 | 주요 작업 | 🆕 | 🔧 | 🔄 | ⚙️ |
|
||||||
|
|------|------|-----------|-----|-----|-----|-----|
|
||||||
|
| 3/2 (월) | [2026-03-02_구현내역.md](./2026-03-02_구현내역.md) | 로드맵 테이블, AI 견적 엔진 | 2 | - | - | - |
|
||||||
|
| 3/3 (화) | [2026-03-03_구현내역.md](./2026-03-03_구현내역.md) | Gemini 업그레이드, 배포 수정, HR 확장, 자재투입 개선 | - | 7 | - | 1 |
|
||||||
|
| 3/4 (수) | [2026-03-04_구현내역.md](./2026-03-04_구현내역.md) | 바로빌 연동, 리스크 대시보드, 지출결의서, 배차 시스템 | 6 | 9 | - | - |
|
||||||
|
| 3/5 (목) | [2026-03-05_구현내역.md](./2026-03-05_구현내역.md) | CEO 대시보드, 어음 V8, 상품권 접대비, 생산지시, 품질관리 | 7 | 7 | 2 | 1 |
|
||||||
|
| 3/6 (금) | [2026-03-06_구현내역.md](./2026-03-06_구현내역.md) | 계정과목/일반전표, 문서 스냅샷, 결재양식 6종, 경조사비 | 7 | 5 | - | 1 |
|
||||||
|
| 3/7 (토) | [2026-03-07_구현내역.md](./2026-03-07_구현내역.md) | 연차촉진 통지서, 품질검사 필터링 | 1 | 1 | - | - |
|
||||||
|
| 3/8 (일) | [2026-03-08_구현내역.md](./2026-03-08_구현내역.md) | 계정과목 확장, 전표 연동 시스템 | - | 1 | - | - |
|
||||||
|
| **합계** | | | **23** | **30** | **2** | **3** |
|
||||||
|
|
||||||
|
### 도메인별 주요 기능
|
||||||
|
|
||||||
|
#### 재무/회계
|
||||||
|
- 🆕 계정과목 및 일반전표 API 신규 구축
|
||||||
|
- 🆕 전표 자동 연동 (카드거래/세금계산서)
|
||||||
|
- 🆕 접대비 상세 조회 API + 리스크 감지
|
||||||
|
- 🆕 부가세 상세 조회 API
|
||||||
|
- 🆕 경조사비 관리 테이블
|
||||||
|
- 🆕 바로빌 연동 API
|
||||||
|
- 🔧 접대비/복리후생비 리스크 감지형 대시보드 전환
|
||||||
|
- 🔧 매출채권 상세 대시보드 개선
|
||||||
|
- 🔧 가지급금 카테고리 분류 (카드/경조사/상품권/접대비)
|
||||||
|
- 🔧 상품권 접대비 자동 연동
|
||||||
|
- 🔧 어음 V8 확장 필드 (54개)
|
||||||
|
|
||||||
|
#### 생산/품질
|
||||||
|
- 🆕 생산지시 전용 API (목록/통계/상세)
|
||||||
|
- 🆕 품질관리서 CRUD API (14개 엔드포인트)
|
||||||
|
- 🆕 실적신고 관리 API (6개 엔드포인트)
|
||||||
|
- 🆕 제품검사 요청서 EAV 자동생성
|
||||||
|
- 🆕 보조 공정(재고생산) 분리
|
||||||
|
- 🔧 절곡 검사 데이터 복제/EAV 변환
|
||||||
|
- 🔧 자재투입 bom_group_key/replace 모드
|
||||||
|
|
||||||
|
#### 전자결재
|
||||||
|
- 🆕 Document ↔ Approval 브릿지 연동
|
||||||
|
- 🆕 결재양식 11종 추가 (지출결의서, 근태신청, 사유서, 재직증명서 등)
|
||||||
|
- 🔧 drafter_read_at, resubmit_count, rejection_history 컬럼
|
||||||
|
|
||||||
|
#### 대시보드/리포트
|
||||||
|
- 🆕 CEO 대시보드 6개 섹션 API
|
||||||
|
- 🆕 일일보고서 엑셀 내보내기
|
||||||
|
- 🔧 자금현황 카드 필드
|
||||||
|
|
||||||
|
#### 출고/배차
|
||||||
|
- 🆕 배차정보 다중 행 시스템
|
||||||
|
- 🆕 배차차량 관리 API
|
||||||
|
|
||||||
|
#### 인프라/기타
|
||||||
|
- ⚙️ Gemini 2.5-flash 업그레이드
|
||||||
|
- 🔧 .env 권한 640 보장 (배포)
|
||||||
|
- ⚙️ Slack 알림 채널 분리
|
||||||
|
- 🆕 문서 rendered_html 스냅샷 API
|
||||||
|
- 🆕 메뉴 즐겨찾기 테이블
|
||||||
|
- 🔧 주소 필드 500자 확장
|
||||||
213
claudedocs/dashboard/[FIX-2026-03-09] ceo-dashboard-fix-plan.md
Normal file
213
claudedocs/dashboard/[FIX-2026-03-09] ceo-dashboard-fix-plan.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# CEO 대시보드 수정계획서 (최종)
|
||||||
|
|
||||||
|
**작성일**: 2026-03-09
|
||||||
|
**기반 문서**: `[QA-2026-03-09] ceo-dashboard-ui-verification.md`
|
||||||
|
**검증 수준**: 화면(Chrome DevTools) + 프론트엔드 코드 + 백엔드 코드 + 실제 데이터 전부 확인 완료
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 최종 이슈 요약
|
||||||
|
|
||||||
|
| 분류 | 건수 | 내용 |
|
||||||
|
|------|------|------|
|
||||||
|
| 🟡 백엔드 개선 | 1건 | 현황판/채권추심 sub_label 필드 추가 |
|
||||||
|
| 🟢 프론트엔드 개선 | 2건 | 더미값 제거, 매입 라벨 명확화 |
|
||||||
|
| ✅ 수정 불필요 (오진 정정) | 8건 | 아래 상세 표 참조 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 수정 필요 항목
|
||||||
|
|
||||||
|
### B3. 현황판/채권추심 거래처 sub_label 필드 추가 🟡
|
||||||
|
|
||||||
|
**현상**: 프론트엔드에서 하드코딩된 더미 거래처명 사용 중
|
||||||
|
|
||||||
|
| 위치 | 더미값 | TODO 주석 |
|
||||||
|
|------|--------|----------|
|
||||||
|
| `transformers/status-issue.ts:20-29` | "주식회사 부산화학 외" 등 | ✅ 있음 (line 18) |
|
||||||
|
| `transformers/receivable.ts:96-103` | "(주)부산화학 외" 등 | ✅ 있음 (line 96) |
|
||||||
|
|
||||||
|
**백엔드 수정 내용**:
|
||||||
|
|
||||||
|
1. **`StatusBoardService.php`** — 각 항목 응답에 `sub_label` 필드 추가
|
||||||
|
- `getBadDebtStatus()` (line 68-83): 최다 금액 거래처 실제 이름 조회
|
||||||
|
- `getNewClientStatus()`: 최근 등록 업체명 조회
|
||||||
|
- 기타 항목도 해당 시 sub_label 제공
|
||||||
|
|
||||||
|
2. **`BadDebtService.php`** — `summary()` 응답에 per-card 거래처 정보 추가
|
||||||
|
- `top_client_name`: 누적 악성채권 최다 금액 거래처명
|
||||||
|
- 카드별 `sub_label`: 해당 카테고리 최다 금액 거래처명 + 건수
|
||||||
|
|
||||||
|
**프론트엔드 후속 작업**: B3 완료 후 → F1 (더미값 제거)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F1. 더미 거래처명 제거 (B3 완료 후) 🟢
|
||||||
|
|
||||||
|
**대상 파일**:
|
||||||
|
- `src/lib/api/dashboard/transformers/status-issue.ts`
|
||||||
|
- Line 20-29: `STATUS_BOARD_FALLBACK_SUB_LABELS` 상수 제거
|
||||||
|
- Line 35-53: `buildStatusSubLabel()` → API `sub_label` 직접 사용
|
||||||
|
|
||||||
|
- `src/lib/api/dashboard/transformers/receivable.ts`
|
||||||
|
- Line 98-103: `DEBT_COLLECTION_FALLBACK_SUB_LABELS` 상수 제거
|
||||||
|
- Line 109-121: `buildDebtSubLabel()` → API 제공 값 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### F3. 매입 섹션 "당월" 컨텍스트 명확화 🟢
|
||||||
|
|
||||||
|
**현상**:
|
||||||
|
- 섹션 subtitle: "당월 매입 실적" + Badge: "당월"
|
||||||
|
- 카드 라벨: "누적 매입" (line 65 — 이것 자체는 정확)
|
||||||
|
- **실제 데이터**: `cumulative_purchase` = 연간 누적 (2026-01-01 ~ 오늘, Feb 포함)
|
||||||
|
- 일별 매입 내역: 2026-02-27 거래 표시 → "당월 매입 내역" 제목 하에 전월 데이터 포함
|
||||||
|
|
||||||
|
**코드 확인**:
|
||||||
|
- `PurchaseStatusSection.tsx:50` — `subtitle="당월 매입 실적"`
|
||||||
|
- `PurchaseStatusSection.tsx:53` — `<Badge>당월</Badge>`
|
||||||
|
- `PurchaseStatusSection.tsx:65` — `<span>누적 매입</span>`
|
||||||
|
- `DashboardCeoService.php:175-180` — `whereYear('purchase_date', $year)` = 연간 누적
|
||||||
|
|
||||||
|
**수정 방향**:
|
||||||
|
- subtitle: "당월 매입 실적" → "매입 실적" 또는 "연간 매입 현황"
|
||||||
|
- Badge: "당월" → 제거 또는 "YTD"로 변경
|
||||||
|
- 하단 카드 title: "당월 매입 내역" → "최근 매입 내역"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 수정 불필요 항목 (최종 정리)
|
||||||
|
|
||||||
|
### 1차 QA → 2차 검증 → 최종 검토로 순차 정정된 이슈들
|
||||||
|
|
||||||
|
| # | 이전 보고 | 최종 검증 결과 | 검증 근거 |
|
||||||
|
|---|----------|-------------|----------|
|
||||||
|
| ~~C1~~ | 5개 섹션 본문 미렌더링 | **LazySection 정상** — 스크롤 시 로드 | `LazySection.tsx` IntersectionObserver + DOM 확인 |
|
||||||
|
| ~~C2~~ | 매출 금액 10배 차이 | **NavBar=누적, 본문=당월 구분 표시** | `SalesStatusSection.tsx:63` "누적 매출" 라벨 확인 |
|
||||||
|
| ~~C3~~ | 발행어음 데이터 불일치 | **다른 테이블 (설계 의도)** | `expected_expenses` 테이블(예측) ≠ `bills` 테이블(실제) |
|
||||||
|
| ~~C4~~ | 채권추심 건수 불일치 | **다른 산출 기준 (설계 의도)** | StatusBoard=레코드 7건(status=collecting) vs BadDebt=거래처 5곳(distinct client_id) |
|
||||||
|
| ~~I2~~ | 카드 월별 합계 0원 버그 | **정상 — 해당 월 데이터 없음** | 카드 거래 20건 모두 2025-01~2026-01-28, 2/3월 거래 0건 |
|
||||||
|
| ~~I3~~ | 미수금 산출 기준 차이 | **다른 산출 방식 (설계 의도)** | 화면 확인 |
|
||||||
|
| ~~M1~~ | 가지급금 카드 금액 차이 | **다른 기준 (설계 의도)** | 가지급금 전환 기준 vs 카드 사용 총액 |
|
||||||
|
| ~~발주~~ | 현황판 "발주" 미표시 | **의도적 숨김** | `STATUS_BOARD_HIDDEN_ITEMS.has('purchases')` (2026-03-03 비활성화) |
|
||||||
|
|
||||||
|
### 상세 정정 사항
|
||||||
|
|
||||||
|
#### ~~B1: 카드 월별 합계 0원~~ — SQL 버그 아님 ✅
|
||||||
|
|
||||||
|
**이전 판단**: `DB::raw('DATE(COALESCE(used_at, withdrawal_date))')` SQL 버그
|
||||||
|
|
||||||
|
**최종 판단**: **데이터가 없어서 0이 정상**
|
||||||
|
|
||||||
|
```
|
||||||
|
카드 거래 20건 날짜 분포:
|
||||||
|
- 가장 오래된 거래: 2025-01-12 (used_at=NULL, withdrawal_date만)
|
||||||
|
- 가장 최근 거래: 2026-01-28 (used_at='2026-01-28')
|
||||||
|
- 2026-02 거래: 0건
|
||||||
|
- 2026-03 거래: 0건
|
||||||
|
→ current_month_total=0, previous_month_total=0 모두 정확
|
||||||
|
```
|
||||||
|
|
||||||
|
**QA 오진 원인**: 카드사용내역 리스트 페이지에서 "15건 표시"를 보고 당월/전월에도 데이터가 있을 것으로 추정했으나, 리스트는 전체 기간 데이터를 표시.
|
||||||
|
|
||||||
|
**참고**: `summary()` 메서드의 SQL 패턴(`DB::raw COALESCE`)이 `index()` 메서드(`whereDate + orWhere`)와 다르지만, 현재 데이터에서는 정상 작동 확인. 향후 코드 일관성을 위해 패턴 통일은 권장하나, 긴급하지 않음.
|
||||||
|
|
||||||
|
#### ~~B2: 현황판 vs 채권추심 건수~~ — 설계 의도 ✅
|
||||||
|
|
||||||
|
**이전 판단**: 건수 통일 필요
|
||||||
|
|
||||||
|
**최종 판단**: **의도적으로 다른 관점 제공**
|
||||||
|
|
||||||
|
| API | 쿼리 | 의미 |
|
||||||
|
|-----|------|------|
|
||||||
|
| `StatusBoardService.php:72` | `where('status', 'collecting')->count()` | "지금 추심중인 건" = 7건 |
|
||||||
|
| `BadDebtService.php:107-111` | `distinct('client_id')->count('client_id')` | "악성채권이 있는 거래처 수" = 5곳 |
|
||||||
|
|
||||||
|
현황판은 "현재 추심 진행 중인 건수"를, 채권추심 본문은 "악성채권 보유 거래처 수"를 보여주는 것으로, 각각 다른 관점의 지표임. 사용자가 혼동할 수 있으나, 정보 제공 목적이 다름.
|
||||||
|
|
||||||
|
#### ~~B5: 매입 "당월" 기간~~ — 백엔드 정상, 프론트 라벨 이슈 ✅
|
||||||
|
|
||||||
|
`DashboardCeoService.php:175-180`의 `cumulative_purchase`는 `whereYear(2026)` = 연간 누적으로 정확히 산출. "당월" 라벨은 프론트엔드 이슈 → F3으로 처리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 수정 우선순위
|
||||||
|
|
||||||
|
| 순위 | 이슈 | 영역 | 난이도 | 비고 |
|
||||||
|
|------|------|------|--------|------|
|
||||||
|
| 1 | B3: sub_label 필드 추가 | 백엔드 | 중 | 거래처 조회 쿼리 추가 |
|
||||||
|
| 2 | F1: 더미 거래처명 제거 | 프론트 | 하 | B3 완료 후 상수/함수 제거 |
|
||||||
|
| 3 | F3: 매입 섹션 라벨 명확화 | 프론트 | 하 | 텍스트만 변경 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 수정 후 재검수 계획
|
||||||
|
|
||||||
|
| 단계 | 항목 | 검증 방법 |
|
||||||
|
|------|------|----------|
|
||||||
|
| 1 | B3+F1 수정 후: 거래처명 | 현황판/채권추심에 실제 거래처명 표시 확인 |
|
||||||
|
| 2 | F3 수정 후: 매입 라벨 | "당월" 대신 적절한 기간 표현 확인 |
|
||||||
|
| 3 | 전체: 상세 모달 | 각 섹션 모달 열기/닫기/날짜필터 동작 확인 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 부록: 관련 파일 위치
|
||||||
|
|
||||||
|
### 백엔드 (sam-api)
|
||||||
|
| 파일 | 이슈 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `app/Services/StatusBoardService.php:68-83` | B3 (sub_label 추가) | 수정 필요 |
|
||||||
|
| `app/Services/BadDebtService.php:107-111` | B3 (per-card 거래처) | 수정 필요 |
|
||||||
|
| `app/Services/CardTransactionService.php:109-153` | ~~B1~~ | ✅ 정상 (데이터 없음이 원인) |
|
||||||
|
| `app/Services/ExpectedExpenseService.php:241-301` | ~~B4~~ | ✅ 정상 (다른 테이블, 설계 의도) |
|
||||||
|
| `app/Services/DashboardCeoService.php:166-234` | ~~B5~~ | ✅ 정상 (프론트 라벨만 수정) |
|
||||||
|
|
||||||
|
### 프론트엔드 (sam-react-prod)
|
||||||
|
| 파일 | 이슈 | 상태 |
|
||||||
|
|------|------|------|
|
||||||
|
| `src/lib/api/dashboard/transformers/status-issue.ts:18-53` | F1 (더미 sub_label) | 수정 필요 (B3 후) |
|
||||||
|
| `src/lib/api/dashboard/transformers/receivable.ts:96-121` | F1 (더미 sub_label) | 수정 필요 (B3 후) |
|
||||||
|
| `src/components/business/CEODashboard/sections/PurchaseStatusSection.tsx:50-53,161` | F3 (라벨) | 수정 필요 |
|
||||||
|
| `src/components/business/CEODashboard/LazySection.tsx` | ~~C1~~ | ✅ 정상 |
|
||||||
|
| `src/components/business/CEODashboard/sections/SalesStatusSection.tsx:63` | ~~F2~~ | ✅ 정상 ("누적 매출" 명확) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 하단 섹션 추가 검증 결과 (3차)
|
||||||
|
|
||||||
|
### 생산/출고/시공/근태 4개 섹션 소스 페이지 대조 + 코드 검증
|
||||||
|
|
||||||
|
| 섹션 | 대시보드 | 소스 페이지 | API 엔드포인트 | 결론 |
|
||||||
|
|------|---------|-----------|-------------|------|
|
||||||
|
| 생산 현황 | 0공정 | 작업지시 39건 (모두 2월/작업대기) | `dashboard/production/summary` | ✅ 정확 (오늘 예정 없음) |
|
||||||
|
| 출고 현황 | 0건/0원 | 당일출고 0건, 전체 8건 (12~1월) | (생산과 동일 API) | ✅ 정확 (당월 건 없음) |
|
||||||
|
| 시공 현황 | 0건 | 시공진행 7/완료 4 (**Mock 데이터**) | `dashboard/construction/summary` | ✅ 비교불가 (소스=Mock) |
|
||||||
|
| 근태 현황 | 0명 전부 | 미출근 55명/출근 0명 | `dashboard/attendance/summary` | ✅ 설계 차이 (기록 vs 명부) |
|
||||||
|
|
||||||
|
### 참고 사항 (향후 개선 검토)
|
||||||
|
|
||||||
|
1. **시공 현황 — NULL end_date 처리** (`DashboardCeoService.php:555-567`)
|
||||||
|
- 현재 쿼리: `contract_end_date >= $monthEnd` 조건에서 NULL 제외됨
|
||||||
|
- 실제 계약 데이터 투입 시, 진행 중(`end_date IS NULL`) 계약이 대시보드에 미표시될 수 있음
|
||||||
|
- 권장: `orWhere(fn($q) => $q->where('start_date', '<=', $monthEnd)->whereNull('end_date'))` 조건 추가 검토
|
||||||
|
|
||||||
|
2. **시공관리 페이지 — API 미연동** (`construction/management/actions.ts`)
|
||||||
|
- `TODO: 실제 API 연동 시 구현` 주석 → 현재 전체가 Mock 데이터
|
||||||
|
- 대시보드 시공 섹션은 실제 `contracts` 테이블 조회 → 소스 페이지와의 정합성 검증은 API 연동 완료 후 재실시
|
||||||
|
|
||||||
|
3. **근태 대시보드 — "미출근" 미표시**
|
||||||
|
- 대시보드는 `attendances` 테이블 레코드 기반 → 출근 기록 없으면 모두 0
|
||||||
|
- 근태관리 페이지는 사원 명부 기반 → "미출근 55명" 표시
|
||||||
|
- CEO 관점에서 "미출근" 정보가 필요한지는 비즈니스 결정 사항
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검증 이력
|
||||||
|
|
||||||
|
| 단계 | 내용 | 결과 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1차 QA | 화면 검수 (18개 섹션) | Critical 4건 + Important 3건 보고 |
|
||||||
|
| 2차 검증 | LazySection + API 응답 확인 | Critical 4건 → 전부 정정 (버그 아님) |
|
||||||
|
| 3차 검토 | 백엔드 코드 + 실제 DB 데이터 확인 | Important 3건 중 1건(카드 0원) 추가 정정 |
|
||||||
|
| 4차 추가 검증 | 하단 4개 섹션 소스 대조 + 코드 검증 | 4건 모두 정상 (참고 3건 기록) |
|
||||||
|
| **최종 결론** | **실제 수정 필요: 3건** (백엔드 1 + 프론트 2) | 나머지 모두 수정 불필요 확인 |
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# CEO 대시보드 UI 검수 결과 (2차 검증 포함)
|
||||||
|
|
||||||
|
**작성일**: 2026-03-09
|
||||||
|
**목적**: 대시보드 전체 18개 섹션의 API 데이터 정합성 및 연동 검증
|
||||||
|
**방법**: 화면 검수 (Chrome DevTools MCP로 실제 화면 조작 + DOM 검증)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검수 범위 요약
|
||||||
|
|
||||||
|
| 구분 | 수량 | 비고 |
|
||||||
|
|------|------|------|
|
||||||
|
| 대시보드 카드 섹션 | 18개 | SummaryNavBar 기준 |
|
||||||
|
| 본문 렌더링 | **18개 전부** | LazySection으로 스크롤 시 로드 (2차 검증) |
|
||||||
|
| 상세 모달 | 10개 | 날짜필터 포함 |
|
||||||
|
| Mock 섹션 (제외) | 2개 | 일별 매출/매입 내역 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: 카드 수치 표출 확인 ✅ 완료
|
||||||
|
|
||||||
|
대시보드 로드 후 각 카드에 표시된 수치를 기록.
|
||||||
|
|
||||||
|
| # | 섹션 | SummaryNavBar 값 | 본문 카드 | 확인 |
|
||||||
|
|---|------|-----------------|----------|------|
|
||||||
|
| 1 | 오늘의 이슈 | 3건 | ✅ 렌더링 | - [x] |
|
||||||
|
| 2 | 자금현황 | 0원 | ✅ 렌더링 (미수금 9억4,697만 / 미지급금 1억5,944만) | - [x] |
|
||||||
|
| 3 | 현황판 | 7항목 | ✅ 렌더링 (수주0/채권추심7/안전재고833/세금신고-/신규업체45/연차0/결재1) | - [x] |
|
||||||
|
| 4 | 당월 예상 지출 | 1억 | ✅ 렌더링 (매입0/카드0/발행어음1억) | - [x] |
|
||||||
|
| 5 | 가지급금 현황 | 1,150만 | ✅ 렌더링 (카드1,150만/경조사0/상품권0/접대비0) | - [x] |
|
||||||
|
| 6 | 접대비 현황 | 0원 | ✅ 렌더링 (리스크 항목 4개 모두 0) | - [x] |
|
||||||
|
| 7 | 복리후생비 현황 | 40만 | ✅ 렌더링 (리스크 항목: 사적사용20만1건/특정인편중20만1건) | - [x] |
|
||||||
|
| 8 | 미수금 현황 | 9.4억 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||||
|
| 9 | 채권추심 현황 | 1.2억 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||||
|
| 10 | 부가세 현황 | 0원 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||||
|
| 11 | 캘린더 | 26일정 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||||
|
| 12 | 매출 현황 | 1.1억 | ✅ **LazySection으로 렌더링** (스크롤 시 로드) | - [x] |
|
||||||
|
| 13 | 매입 현황 | 165만 | ✅ 렌더링 (당월 누적매입165만 / 미결제165만 / 차트+테이블) | - [x] |
|
||||||
|
| 14 | 생산 현황 | 0공정 | ✅ 렌더링 (작업지시 없음) | - [x] |
|
||||||
|
| 15 | 출고 현황 | 0건 | ✅ 렌더링 (7일이내0 / 30일이내0) | - [x] |
|
||||||
|
| 16 | 미출고 내역 | 6건 | ✅ 렌더링 (6건 상세목록 표시) | - [x] |
|
||||||
|
| 17 | 시공 현황 | 0건 | ✅ 렌더링 (시공진행0/시공완료0) | - [x] |
|
||||||
|
| 18 | 근태 현황 | 0명 | ✅ 렌더링 (출근0/휴가0/지각0/결근0) | - [x] |
|
||||||
|
|
||||||
|
### 2차 검증: LazySection 확인 (1차 QA 오류 정정)
|
||||||
|
|
||||||
|
1차 QA에서 "본문 미렌더링"으로 보고된 5개 섹션(미수금/채권추심/부가세/캘린더/매출)은 실제로는 **LazySection**(IntersectionObserver 기반 lazy loading)으로 정상 작동합니다. 스크롤하여 뷰포트에 진입하면 콘텐츠가 로드됩니다.
|
||||||
|
|
||||||
|
**확인 방법**:
|
||||||
|
- DOM 검사: `[data-section-key]` 18개 전부 존재 확인
|
||||||
|
- 스크롤 후 콘텐츠 확인: 5개 섹션 모두 데이터 정상 렌더링
|
||||||
|
- LazySection.tsx 분석: IntersectionObserver + rootMargin='300px' 패턴
|
||||||
|
|
||||||
|
**스크롤 후 확인된 본문 데이터**:
|
||||||
|
| 섹션 | 본문 주요 수치 | NavBar 값 | 일치 |
|
||||||
|
|------|--------------|----------|------|
|
||||||
|
| 미수금 | 누적 미수금 9억 4,164만 / 미수금 거래처 79건 / 연체 1건 / 악성채권 11건 | 9.4억 | ✅ |
|
||||||
|
| 채권추심 | 누적 악성채권 1억 1,869만 / (주)부산화학 외 4건 | 1.2억 | ✅ |
|
||||||
|
| 부가세 | 매출세액 0원 / 매입세액 0원 / 예상 납부세액 0원 / 미발행 1건 | 0원 | ✅ |
|
||||||
|
| 캘린더 | 2026년 3월 전체 일정 표시 | 26일정 | ✅ |
|
||||||
|
| 매출 | 당월누적 매출 1억 673만 / 달성률 6% / 전년대비 -93.6% / 당월 매출 1,045만 | 1.1억 | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 상세 모달 + 날짜필터 검증
|
||||||
|
|
||||||
|
### 2-4. 복리후생비 상세 모달 ✅ (검증 완료)
|
||||||
|
| 테스트 | 방법 | 확인 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 모달 열기 | 카드 클릭 → 요약/차트/테이블 확인 | - [x] 완료 |
|
||||||
|
| 당월 날짜필터 | 당월 → 데이터 있음 (1건 200,000) | - [x] 완료 |
|
||||||
|
| 전월 날짜필터 | 전월 → 데이터 없음 (0건) | - [x] 완료 |
|
||||||
|
|
||||||
|
### 나머지 모달 (Phase 2)
|
||||||
|
> 당월 예상 지출, 가지급금, 접대비 등 나머지 모달은 하단 수정계획에 따라 이슈 수정 후 재검수 예정.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: 소스 페이지 ↔ 대시보드 데이터 연동 검증 ✅ 완료
|
||||||
|
|
||||||
|
### 3-1. 복리후생비 (세금계산서 분개) ✅ 검증 완료
|
||||||
|
| 테스트 | 소스 페이지 | 결과 | 확인 |
|
||||||
|
|--------|-----------|------|------|
|
||||||
|
| 분개 추가 | 세금계산서관리 | 대시보드 금액 변동 확인 (51만→31만) | - [x] ✅ |
|
||||||
|
| 계정 변경 | 세금계산서관리 | 대시보드 금액 변동 확인 (51만→40만) | - [x] ✅ |
|
||||||
|
| 날짜필터 | 대시보드 모달 | 전월 변경 → 0건 표시 | - [x] ✅ |
|
||||||
|
|
||||||
|
### 3-2. 미수금 현황 ⚠️ 산출 기준 다름
|
||||||
|
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||||
|
|--------|---------|-----------|------|
|
||||||
|
| 미수금 잔액 | 9억 4,697만 | 미수금현황 합계 미수금 = **음수** (-311,979,400) | ⚠️ 산출 기준 불일치 |
|
||||||
|
|
||||||
|
> 대시보드의 미수금은 자금현황 카드 내 "미수금 잔액"으로 표시. 미수금현황 페이지의 합계 행은 월별 차이금액의 합산으로 음수 표시. 두 페이지의 산출 기준이 완전히 다름.
|
||||||
|
|
||||||
|
### 3-3. 매출 현황 ✅ 정정 (2차 검증)
|
||||||
|
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||||
|
|--------|---------|-----------|------|
|
||||||
|
| 매출 금액 (NavBar) | 1.1억 | cumulative_sales = 106,726,323 (1.07억) | ✅ NavBar는 누적매출 표시 (반올림 1.1억) |
|
||||||
|
| 매출 금액 (본문) | 당월누적 1억 673만 / 당월 1,045만 | 매출관리 당월 매출 = 10,450,000원 | ✅ 본문에서 구분 표시 |
|
||||||
|
|
||||||
|
> **1차 QA 오류 정정**: NavBar "1.1억"은 `cumulative_sales`(누적매출)이며, 본문에서는 "당월누적 매출 1억 673만"과 "당월 매출 1,045만"을 구분 표시. 10배 차이가 아닌 다른 지표 표시.
|
||||||
|
|
||||||
|
### 3-4. 매입 현황 ✅ 일치
|
||||||
|
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||||
|
|--------|---------|-----------|------|
|
||||||
|
| 매입 금액 | 165만 | 매입관리 합계 = **1,650,000원** | ✅ 일치 |
|
||||||
|
|
||||||
|
> 단, "당월" 라벨이지만 데이터는 2026-02-27 것임 (3월 매입 없음). 라벨 정확성 재검토 필요.
|
||||||
|
|
||||||
|
### 3-5. 당월 예상 지출 (발행어음) ⚠️ 소스 확인 필요
|
||||||
|
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||||
|
|--------|---------|-----------|------|
|
||||||
|
| 발행어음 | 1억 | 어음관리 당월 = 수취어음 2건 40,000원 **(발행어음 0건)** | ⚠️ 다른 데이터 소스 |
|
||||||
|
|
||||||
|
> 대시보드의 발행어음 1억은 `expected-expenses` API에서 `by_transaction_type.bill.total = 100,000,000`으로 제공. 어음관리 페이지(`bills` 테이블)와 다른 데이터 소스(`expected_expenses` 테이블) 사용. **최종 확인: 설계 의도** — expected_expenses는 수동 입력된 지출 예측 데이터이며, bills는 실제 발행어음 문서. 두 시스템은 독립적.
|
||||||
|
|
||||||
|
### 3-6. 가지급금 현황 ⚠️ 기준 다름
|
||||||
|
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||||
|
|--------|---------|-----------|------|
|
||||||
|
| 카드 | 1,150만 | 카드사용내역 당월 합계 ≈ 467만 | ⚠️ 기준 다름 (가지급금 전환 기준) |
|
||||||
|
| 상품권 | 0원 | 상품권관리 보유 0건/0원 | ✅ 일치 |
|
||||||
|
|
||||||
|
> ~~카드사용내역 요약(전월/당월/건수)이 모두 0원/0건으로 표시 — API 버그~~
|
||||||
|
> **최종 확인: 버그 아님** — 카드 거래 20건의 날짜 범위가 2025-01~2026-01-28이며, 2026년 2월/3월 거래는 0건. 따라서 전월/당월 합계 0원은 정확한 값.
|
||||||
|
|
||||||
|
### 3-7. 미출고 내역 ✅ 대시보드 내 확인
|
||||||
|
| 테스트 | 대시보드 | 결과 |
|
||||||
|
|--------|---------|------|
|
||||||
|
| 미출고 | 6건 | 대시보드 카드 내 6건 상세목록 표시 (LOT번호, 현장명, 납기일 포함) | ✅ |
|
||||||
|
|
||||||
|
### 3-8. 채권추심 현황 ⚠️ 건수 불일치 + 더미 거래처명
|
||||||
|
| 테스트 | 대시보드 | 소스 페이지 | 결과 |
|
||||||
|
|--------|---------|-----------|------|
|
||||||
|
| 금액 | 본문 1억 1,869만 / NavBar 1.2억 | 악성채권 5건 합계 ≈ 1.19억 | ✅ 일치 |
|
||||||
|
| 건수 (현황판) | 7건 | 악성채권관리 = **5건** | ⚠️ status-board API 별도 산출 |
|
||||||
|
| 건수 (채권추심 본문) | 5건 (client_count) | 악성채권관리 = 5건 | ✅ 일치 |
|
||||||
|
| 거래처명 | "(주)부산화학 외 4건" | 실제 거래처 미확인 | ⚠️ **하드코딩 더미값** |
|
||||||
|
|
||||||
|
> **2차 검증 발견**: 채권추심 본문/현황판의 거래처명("부산화학", "삼성테크" 등)은 `DEBT_COLLECTION_FALLBACK_SUB_LABELS`와 `STATUS_BOARD_FALLBACK_SUB_LABELS`에 하드코딩된 **더미값**. 코드에 `// TODO: 백엔드 per-card sub_label/count 제공 시 더미값 제거` 주석 있음.
|
||||||
|
|
||||||
|
### 3-9. 현황판 "발주" 미표시 ✅ 의도적 숨김 (2차 검증)
|
||||||
|
|
||||||
|
> `STATUS_BOARD_HIDDEN_ITEMS`에 `purchases`가 포함되어 의도적으로 숨김 처리. 사용자 설정에서도 `purchase: false`. 백엔드 path 오류 + 데이터 정합성 이슈 해결 전까지 비활성화 (코드 주석: `[2026-03-03] 비활성화`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 발견된 이슈 요약 (최종 검토 반영)
|
||||||
|
|
||||||
|
### 🔴 Critical → 없음 (1차 이슈 모두 정정)
|
||||||
|
|
||||||
|
1차 QA의 Critical 이슈 4건은 2차 검증에서 모두 재분류됨:
|
||||||
|
- ~~C1 (5개 섹션 미렌더링)~~: LazySection 정상 → **이슈 아님**
|
||||||
|
- ~~C2 (매출 10배 차이)~~: NavBar=누적, 본문=당월 구분 → **이슈 아님**
|
||||||
|
- ~~C3 (발행어음 불일치)~~: `expected_expenses` 테이블(예측) ≠ `bills` 테이블(실제) → **설계 의도**
|
||||||
|
- ~~C4 (채권추심 건수)~~: StatusBoard=레코드 7건 vs BadDebt=거래처 5곳 → **설계 의도**
|
||||||
|
|
||||||
|
### 🟡 Important (실제 수정 필요: 3건)
|
||||||
|
|
||||||
|
| # | 이슈 | 상세 | 조치 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| I1 | **채권추심/현황판 더미 거래처명** | "(주)부산화학" 등 하드코딩 — 실제 거래처가 아님 | 백엔드 sub_label 필드 추가 → 프론트 더미값 제거 |
|
||||||
|
| ~~I2~~ | ~~현황판 vs 채권추심 건수 불일치~~ | 현황판=`status=collecting` 레코드 7건, 채권추심=`distinct(client_id)` 거래처 5곳 | **설계 의도** (다른 관점 지표) |
|
||||||
|
| ~~I3~~ | ~~카드사용내역 월별 합계 0원~~ | 카드 거래 20건 전부 2025-01~2026-01-28, 2/3월 거래 0건 | **버그 아님** (데이터 없음이 원인) |
|
||||||
|
| ~~I4~~ | ~~발행어음 데이터 소스 불명확~~ | `expected_expenses`(예측)와 `bills`(실제)는 별도 테이블 | **설계 의도** (독립 데이터) |
|
||||||
|
| I5 | **매입 "당월" 라벨 부정확** | subtitle "당월 매입 실적" + Badge "당월"이나 실제 데이터는 연간 누적(`whereYear`) | 프론트엔드 라벨 수정 |
|
||||||
|
|
||||||
|
### 🟢 Minor → 수정 불필요 (최종 확인)
|
||||||
|
|
||||||
|
| # | 이슈 | 최종 판단 |
|
||||||
|
|---|------|----------|
|
||||||
|
| ~~M1~~ | 미수금 산출 기준 차이 | **설계 의도** — 다른 산출 방식 |
|
||||||
|
| ~~M2~~ | 가지급금 카드 금액 대조 불가 | **설계 의도** — 가지급금 전환 기준 vs 카드 사용 총액 |
|
||||||
|
|
||||||
|
### 최종 수정 필요 항목: 3건만
|
||||||
|
|
||||||
|
| 순위 | 이슈 | 영역 | 내용 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 1 | I1(B3) | 백엔드 | StatusBoardService/BadDebtService에 sub_label 필드 추가 |
|
||||||
|
| 2 | I1(F1) | 프론트 | 더미 거래처명 상수/함수 제거 → API sub_label 사용 |
|
||||||
|
| 3 | I5(F3) | 프론트 | 매입 섹션 "당월" → "연간"/"YTD" 라벨 수정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: 하단 섹션 추가 검증 (생산/출고/시공/근태) ✅ 완료
|
||||||
|
|
||||||
|
### 4-1. 생산 현황 (0공정) ✅ 정확
|
||||||
|
|
||||||
|
| 항목 | 대시보드 | 소스 페이지 (작업지시 관리) | 결과 |
|
||||||
|
|------|---------|--------------------------|------|
|
||||||
|
| 공정 수 | 0공정 | 전체 39건 (작업대기 39, 작업중 0, 완료 0) | ✅ |
|
||||||
|
| 본문 | "오늘 등록된 작업 지시가 없습니다" | 39건 모두 2월 날짜, 상태 "미배정" | ✅ |
|
||||||
|
|
||||||
|
> **검증**: 대시보드 API(`dashboard/production/summary`)는 `scheduled_date = today` 기준 조회. 39건의 작업지시는 모두 2026년 2월 날짜이므로 오늘(3월 9일) 예정 작업 없음 → 0공정 정확.
|
||||||
|
>
|
||||||
|
> **백엔드 코드**: `DashboardCeoService.php` — `work_orders` 테이블에서 `scheduled_date = today`, `is_active = true` 조건으로 공정별 집계. 출고 데이터도 동일 API에서 `shipment` 필드로 제공.
|
||||||
|
|
||||||
|
### 4-2. 출고 현황 (0건/0원) ✅ 정확
|
||||||
|
|
||||||
|
| 항목 | 대시보드 | 소스 페이지 (출고관리) | 결과 |
|
||||||
|
|------|---------|---------------------|------|
|
||||||
|
| 예상 출고 (7일 이내) | 0건/0원 | 당일 출고대기 0건 | ✅ |
|
||||||
|
| 예상 출고 (30일 이내) | 0건/0원 | 전체 8건 (모두 2025-12~2026-01) | ✅ |
|
||||||
|
|
||||||
|
> **검증**: 출고관리 페이지의 8건은 모두 2025년 12월~2026년 1월 날짜. 대시보드는 당월(3월) 기준 `status IN ('scheduled','ready')` 필터 → 해당 없음 → 0 정확.
|
||||||
|
>
|
||||||
|
> **미출고 6건**: `dashboard/unshipped/summary` API로 별도 조회. LOT번호(LOT-2024001~008), 현장명, 납기일 모두 소스 데이터와 일치. days_left가 모두 음수(D-64~D-69) → 납기 초과 상태.
|
||||||
|
|
||||||
|
### 4-3. 시공 현황 (0건) ✅ 비교 불가 (소스=Mock)
|
||||||
|
|
||||||
|
| 항목 | 대시보드 | 소스 페이지 (시공관리) | 결과 |
|
||||||
|
|------|---------|---------------------|------|
|
||||||
|
| 시공 진행 | 0건 | 시공진행 7건 | ⚠️ 차이 |
|
||||||
|
| 시공 완료 | 0건 | 시공완료 4건 | ⚠️ 차이 |
|
||||||
|
|
||||||
|
> **원인 분석**: 시공관리 페이지(`construction/management/actions.ts`)는 **Mock 데이터 사용 중** (line 22: `// 목업 데이터`, line 21: `TODO: 실제 API 연동 시 구현`). 화면에 표시되는 "시공진행 7건"은 하드코딩된 가짜 데이터.
|
||||||
|
>
|
||||||
|
> 대시보드는 실제 `contracts` 테이블 조회 (`DashboardCeoService.php:555-567`) — `contract_start_date`/`contract_end_date`가 당월(3월) 범위에 해당하는 계약 없음 → 0건 정확.
|
||||||
|
>
|
||||||
|
> **참고**: `contracts` 테이블에서 `end_date IS NULL`인 진행 중 계약 처리 — 현재 쿼리는 `contract_end_date >= $monthEnd` 조건에서 NULL이 제외됨. 실제 계약 데이터 투입 시 이 조건의 적정성 재검토 권장 (NULL end_date = 아직 진행 중).
|
||||||
|
|
||||||
|
### 4-4. 근태 현황 (0명) ✅ 설계 차이
|
||||||
|
|
||||||
|
| 항목 | 대시보드 | 소스 페이지 (근태관리) | 결과 |
|
||||||
|
|------|---------|---------------------|------|
|
||||||
|
| 출근 | 0명 | 정시 출근 0명 | ✅ |
|
||||||
|
| 지각 | 0명 | 지각 0명 | ✅ |
|
||||||
|
| 휴가 | 0명 | 휴가 0명 | ✅ |
|
||||||
|
| 결근 | 0명 | - | ✅ |
|
||||||
|
| 미출근 | (미표시) | **55명** | ⚠️ 관점 차이 |
|
||||||
|
|
||||||
|
> **검증**: 대시보드 API(`dashboard/attendance/summary`)는 `attendances` 테이블에서 `base_date = today` 레코드만 조회 (`DashboardCeoService.php:677-694`). 오늘 출근 기록이 없으므로 모든 카운트 0, employees 배열 비어있음.
|
||||||
|
>
|
||||||
|
> 근태관리 페이지는 **전체 사원 명부 기반** — 등록된 55명의 사원에 대해 출근 기록 유무를 확인하고, 기록 없으면 "미출근"으로 표시.
|
||||||
|
>
|
||||||
|
> **설계 차이**: 대시보드="출근 기록 기반"(기록 있는 것만 카운트), 관리 페이지="사원 명부 기반"(전체 사원 대비 상태 표시). 대시보드에서 "미출근" 정보를 보여줄지는 비즈니스 결정 사항.
|
||||||
|
>
|
||||||
|
> **참고**: 55명 전원 "E2E_TEST_사원"(테스트 데이터), 부서/직책 모두 미지정. 실 운영 시에는 출근 기록이 생성되므로 정상 동작 예상.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검수 완료 항목
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|------|------|
|
||||||
|
| Phase 1: 전체 18개 카드 수치 기록 | ✅ 완료 |
|
||||||
|
| Phase 1: LazySection 5개 섹션 재확인 | ✅ 완료 (2차) |
|
||||||
|
| Phase 2: 복리후생비 모달/날짜필터 | ✅ 완료 |
|
||||||
|
| Phase 3: 소스 페이지 대조 (9개 항목) | ✅ 완료 |
|
||||||
|
| Phase 3: 복리후생비 데이터 변경 반영 | ✅ 완료 |
|
||||||
|
| Phase 3: 코드 분석 (transformer/fallback) | ✅ 완료 (2차) |
|
||||||
|
| Phase 4: 하단 섹션 추가 검증 (생산/출고/시공/근태) | ✅ 완료 (3차) |
|
||||||
|
| Phase 5: 데이터 변경 반영 테스트 | ⏸ 이슈 수정 후 |
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
# CEO 대시보드 데이터 흐름 검증 보고서
|
||||||
|
|
||||||
|
> **작성일**: 2026-03-06
|
||||||
|
> **목적**: 대시보드 ↔ 개별 페이지 간 데이터 연동 완전성 검증
|
||||||
|
> **🔴 이 문서에 정리된 데이터 레이어는 "확정된 인프라"로 고정. 디자인 변경 시 UI만 교체할 것.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 변경 금지 영역 (데이터 인프라)
|
||||||
|
|
||||||
|
디자인 변경 시 아래 파일들은 **절대 수정하지 않음**:
|
||||||
|
|
||||||
|
| 레이어 | 파일 | 역할 |
|
||||||
|
|--------|------|------|
|
||||||
|
| **Hooks** | `src/hooks/useCEODashboard.ts` | 23개 Hook, API 호출 |
|
||||||
|
| **Transformers** | `src/lib/api/dashboard/transformers/*.ts` | API→Frontend 변환 |
|
||||||
|
| **Types (API)** | `src/lib/api/dashboard/types.ts` | API 응답 타입 |
|
||||||
|
| **Types (UI)** | `src/components/business/CEODashboard/types.ts` | UI 컴포넌트 타입 |
|
||||||
|
| **Modal Configs** | `src/components/business/CEODashboard/modalConfigs/*.ts` | 모달 설정 |
|
||||||
|
|
||||||
|
디자인 변경 시 수정 가능한 파일:
|
||||||
|
- `sections/*.tsx` (JSX/CSS만)
|
||||||
|
- `CEODashboard.tsx` (레이아웃만)
|
||||||
|
- `components.tsx` (공통 UI 컴포넌트)
|
||||||
|
- `SummaryNavBar.tsx` (네비게이션)
|
||||||
|
- `skeletons/*.ts` (로딩 UI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 전체 20개 섹션 데이터 흐름 매핑
|
||||||
|
|
||||||
|
### 1. 상품권 → 가지급금 → 접대비 (핵심 연관관계)
|
||||||
|
|
||||||
|
```
|
||||||
|
상품권 관리 (/accounting/gift-certificate)
|
||||||
|
├─ 등록: status='holding' → cm3(상품권) 카운트 증가, 접대비 미반영
|
||||||
|
├─ 수정: status='used' + entertainmentExpense='applicable'
|
||||||
|
│ → Backend: syncGiftCertificateExpense() 자동 실행
|
||||||
|
│ → expense_accounts INSERT (account_type='entertainment')
|
||||||
|
│ → 접대비 섹션 반영됨
|
||||||
|
├─ 조건별 접대비 분류:
|
||||||
|
│ ├─ 일련번호 없음 → et_no_receipt (증빙미비) ✅
|
||||||
|
│ ├─ 금액 > 50만원 → et_high_amount (고액결제) ✅
|
||||||
|
│ └─ 주말/심야 사용 → et_weekend (주말/심야) ✅
|
||||||
|
└─ 삭제: expense_accounts도 함께 삭제
|
||||||
|
```
|
||||||
|
|
||||||
|
**검증 시나리오:**
|
||||||
|
| # | 작업 | 기대 결과 (카드관리) | 기대 결과 (접대비) |
|
||||||
|
|---|------|-------------------|------------------|
|
||||||
|
| 1 | 상품권 100만원 등록 (holding) | cm3 금액 +100만원 | 미반영 |
|
||||||
|
| 2 | status → used, 접대비=해당 | cm3 유지 | 접대비 총액 +100만원, 고액결제 +1건 |
|
||||||
|
| 3 | 일련번호 삭제 | cm3 미증빙 +1건 | 증빙미비 +1건 |
|
||||||
|
| 4 | status → holding 복귀 | cm3 유지 | 접대비에서 제거 |
|
||||||
|
| 5 | 상품권 삭제 | cm3 금액 -100만원 | 접대비에서 제거 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 미수금 (ReceivableSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
매출관리 (/accounting/sales) → Sale 생성 → receivable_balance 증가
|
||||||
|
미수금현황 (/accounting/receivables-status) → 입금처리/연체설정
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/receivables/summary
|
||||||
|
↓
|
||||||
|
useReceivable() → transformReceivableResponse() → ReceivableSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**데이터 소스 → 대시보드 매핑:**
|
||||||
|
| 소스 페이지 | 작업 | 대시보드 반영 |
|
||||||
|
|-----------|------|------------|
|
||||||
|
| 매출관리 | 매출 등록 | 누적미수금 증가 |
|
||||||
|
| 미수금현황 | 입금 처리 | 누적미수금 감소 |
|
||||||
|
| 어음관리 | 어음 발행 | 미수금 일부 이월 |
|
||||||
|
| 미수금현황 | 연체 설정 | 체크포인트 메시지 변경 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 채권추심 (DebtCollectionSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
악성채권관리 (/accounting/bad-debt-collection) → BadDebt CRUD
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/bad-debts/summary
|
||||||
|
↓
|
||||||
|
useDebtCollection() → transformDebtCollectionResponse() → DebtCollectionSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**상태 전환:**
|
||||||
|
| 상태 | 카드 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| collecting | 추심중 | 채권 추심 진행 |
|
||||||
|
| legalAction | 법적조치 | 법적 절차 진행 |
|
||||||
|
| recovered | 회수완료 | 채권 회수 완료 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 매출현황 (SalesStatusSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
매출관리 (/accounting/sales) → Sale CRUD
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/dashboard/sales/summary
|
||||||
|
↓
|
||||||
|
useSalesStatus() → transformSalesStatusResponse() → SalesStatusSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**대시보드 표시:** 누적매출, 달성률, 전년동기대비, 당월매출, 월별추이차트, 거래처별차트, 일별내역
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. 구매현황 (PurchaseStatusSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
매입관리 (/accounting/purchases) → Purchase CRUD
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/dashboard/purchases/summary
|
||||||
|
↓
|
||||||
|
usePurchaseStatus() → transformPurchaseStatusResponse() → PurchaseStatusSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**결제 상태 매핑:**
|
||||||
|
| DB 상태 | 표시 | 조건 |
|
||||||
|
|--------|------|------|
|
||||||
|
| paid | 결제완료 | withdrawal_id 있음 |
|
||||||
|
| unpaid | 미결제 | withdrawal_id 없음 |
|
||||||
|
| partial | 부분결제 | 일부만 결제 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. 카드/가지급금 (CardManagementSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
카드거래 + 가지급금(Loan) 데이터
|
||||||
|
↓
|
||||||
|
API: GET /api/proxy/card-transactions/summary + /loans/dashboard + /loans/tax-simulation
|
||||||
|
↓
|
||||||
|
useCardManagement() → transformCardManagementResponse() → CardManagementSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**5개 카드:** cm1(카드), cm2(경조사), cm3(상품권), cm4(접대비), cm_total(합계)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. 접대비 (EntertainmentSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
expense_accounts 테이블 (상품권/카드 접대비 전환 시 자동 INSERT)
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/entertainment/summary
|
||||||
|
↓
|
||||||
|
useEntertainment() → transformEntertainmentResponse() → EntertainmentSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**4개 리스크 카드:**
|
||||||
|
| 카드 | 조건 |
|
||||||
|
|------|------|
|
||||||
|
| 주말/심야 | expense_date가 토/일/심야 |
|
||||||
|
| 기피업종 | merchant_biz_type MCC 매칭 |
|
||||||
|
| 고액결제 | amount > 500,000원 |
|
||||||
|
| 증빙미비 | receipt_no IS NULL |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. 복리후생비 (WelfareSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
지출 결재 승인 → 복리후생 관련 지출 집계
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/welfare/summary
|
||||||
|
↓
|
||||||
|
useWelfare() → transformWelfareResponse() → WelfareSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**4개 리스크 카드:** 비과세한도초과, 사적사용의심, 특정인편중, 항목별한도초과
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. 부가세 (VatSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
매출/매입 거래 → 부가세 자동 계산
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/vat/summary
|
||||||
|
↓
|
||||||
|
useVat() → transformVatResponse() → VatSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**신고 기한 색상:** D-15+(녹색), D-1~15(주황), D-0(빨강), D-(음수)(진빨강경고)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. 당월 예상 지출 (MonthlyExpenseSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
구매발주 + 카드결제 + 어음 → 유형별 집계
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/expected-expenses/summary
|
||||||
|
↓
|
||||||
|
useMonthlyExpense() → transformMonthlyExpenseResponse() → MonthlyExpenseSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**4개 카드:** 구매금액, 카드결제, 어음/외상, 전체합계
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 11. 일일일보 (DailyReportSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
배송완료(매출) + 입금기록 + 결재완료(지출) → 오늘 기준 집계
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/daily-report/summary
|
||||||
|
↓
|
||||||
|
useDailyReport() → transformDailyReportResponse() → DailyReportSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**4개 카드:** 당일매출액, 당일입금액, 당일지출액, 당일순현금
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 12. 현황판 (StatusBoardSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
각 도메인 페이지 → 미처리 건수 집계
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/status-board/summary
|
||||||
|
↓
|
||||||
|
useStatusBoard() → transformStatusBoardResponse() → StatusBoardSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**항목:** 수주, 채권추심, 안전재고, 세금신고, 신규업체, 연차, 차량, 장비, 결재요청
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 13. 오늘의 이슈 (TodayIssueSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
각 도메인 이벤트 발생 → TodayIssue 자동 생성
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/today-issues/summary
|
||||||
|
↓
|
||||||
|
useTodayIssue() → transformTodayIssueResponse() → TodayIssueSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**이슈 타입:** sales_order, bad_debt, safety_stock, expected_expense, vat_report, approval_request, new_vendor, deposit, withdrawal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 14. 일정/캘린더 (CalendarSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
일정관리 + 발주일정 + 시공일정 + 공휴일/세무일정(상수)
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/calendar/schedules
|
||||||
|
↓
|
||||||
|
useCalendar() → transformCalendarResponse() → CalendarSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**일정 타입:** schedule(파랑), order(초록), construction(보라), holiday(빨강), tax(주황)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 15. 일일생산 (DailyProductionSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
작업지시 상태변경 → 공정별 집계 (오늘만)
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/dashboard/production/summary
|
||||||
|
↓
|
||||||
|
useDailyProduction() → transformDailyProductionResponse() → DailyProductionSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**공정별 탭:** 각 공정(스크린 등)의 전체/대기/진행/완료/긴급 카운트 + 작업자 진행률
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 16. 출하현황 (DailyProduction 내 ShipmentSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
shipments 테이블 → 당월 예상/실제 출고 집계
|
||||||
|
↓
|
||||||
|
production/summary API 내 shipment 필드
|
||||||
|
↓
|
||||||
|
DailyProductionSection 내 출하현황 카드
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 17. 미출하 (UnshippedSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
출하관리 → shipments status='scheduled'|'ready'
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/dashboard/unshipped/summary
|
||||||
|
↓
|
||||||
|
useUnshipped() → transformUnshippedResponse() → UnshippedSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**납기 색상:** ≤3일(빨강), ≤7일(주황), 이상(회색)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 18. 공사현황 (ConstructionSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
계약관리 → contracts 당월 포함 건
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/dashboard/construction/summary
|
||||||
|
↓
|
||||||
|
useConstruction() → transformConstructionResponse() → ConstructionSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**진행률:** (경과일/총일수) × 100, 완료=100%, 미시작=0%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 19. 일일근태 (DailyAttendanceSection)
|
||||||
|
|
||||||
|
```
|
||||||
|
출퇴근기록 + 휴가신청 → 오늘 기준 분류
|
||||||
|
↓
|
||||||
|
API: GET /api/v1/dashboard/attendance/summary
|
||||||
|
↓
|
||||||
|
useDailyAttendance() → transformDailyAttendanceResponse() → DailyAttendanceSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**상태 분류:** checkin ≤ 기준=출근, checkin > 기준=지각, leave=휴가, 없음=결근
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 20. Enhanced 섹션 (EnhancedSections.tsx)
|
||||||
|
|
||||||
|
일별 매출/매입 상세 내역 — SalesStatus/PurchaseStatus API의 daily_items 활용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚡ 공통 갱신 메커니즘
|
||||||
|
|
||||||
|
- **자동 갱신 없음**: 대시보드는 수동 refetch() 또는 페이지 새로고침 시에만 갱신
|
||||||
|
- **sam_stat 5분 캐시**: 백엔드 통계 테이블 캐싱 (일부 섹션)
|
||||||
|
- **대시보드 진입 시**: useCEODashboard()가 모든 섹션 병렬 로드 (Promise.all)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 화면 검수 시나리오 (2단계용)
|
||||||
|
|
||||||
|
### 시나리오 A: 상품권 → 가지급금 → 접대비
|
||||||
|
1. 상품권 100만원 등록 (holding) → 카드관리 cm3 확인
|
||||||
|
2. status=used, 접대비=해당으로 수정 → 접대비 고액결제 확인
|
||||||
|
3. 일련번호 제거 → 접대비 증빙미비 확인
|
||||||
|
4. 상태 복귀 → 접대비에서 제거 확인
|
||||||
|
|
||||||
|
### 시나리오 B: 매출 → 미수금
|
||||||
|
1. 매출 등록 → 매출현황 + 미수금 증가 확인
|
||||||
|
2. 입금 처리 → 미수금 감소 확인
|
||||||
|
|
||||||
|
### 시나리오 C: 작업지시 → 생산현황
|
||||||
|
1. 작업지시 등록 (오늘) → 생산현황 대기 +1 확인
|
||||||
|
2. 상태 → 진행중 → 진행 +1, 대기 -1 확인
|
||||||
|
3. 상태 → 완료 → 완료 +1, 진행 -1 확인
|
||||||
|
|
||||||
|
### 시나리오 D: 근태
|
||||||
|
1. 출근 기록 → 출근 인원 +1 확인
|
||||||
|
2. 휴가 신청 승인 → 휴가 +1 확인
|
||||||
|
|
||||||
|
### 시나리오 E: 구매 → 지출
|
||||||
|
1. 구매 등록 → 구매현황 + 당월예상지출 증가 확인
|
||||||
|
2. 결제 처리 → 구매현황 미결제→결제완료 변경 확인
|
||||||
|
|
||||||
|
### 시나리오 F: 일일일보
|
||||||
|
1. 배송 완료 → 당일매출액 증가 확인
|
||||||
|
2. 입금 기록 → 당일입금액 증가 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 화면 검수 결과 (2026-03-06 실행)
|
||||||
|
|
||||||
|
### 시나리오 A: 상품권 → 가지급금 → 접대비 (CRUD 전체 사이클 검증)
|
||||||
|
|
||||||
|
| Step | 작업 | 가지급금 상품권 | 접대비 | 결과 |
|
||||||
|
|------|------|----------------|--------|------|
|
||||||
|
| 1 | 100만원 등록 (holding) | 0→100만 | 미반영 | ✅ PASS |
|
||||||
|
| 2 | status→사용, 접대비=해당 | 100만→0원 | 고액결제 +100만 1건 | ✅ PASS |
|
||||||
|
| 3 | 일련번호 삭제 | 0원 유지 | 증빙미비 10만1건→110만2건 | ✅ PASS |
|
||||||
|
| 4 | status→보유 복귀 | 0→100만 복귀 | 접대비에서 전부 제거 | ✅ PASS |
|
||||||
|
| 5 | 상품권 삭제 | 100만→0원 | 변화 없음 | ✅ PASS |
|
||||||
|
|
||||||
|
**검증 결론**: 상품권↔가지급금↔접대비 양방향 연동 완벽 작동
|
||||||
|
|
||||||
|
### 전체 20개 섹션 데이터 일관성 검증 (대시보드 vs 소스 페이지)
|
||||||
|
|
||||||
|
| # | 섹션 | NavBar 값 | 상세 섹션 값 | API 연동 | 결과 |
|
||||||
|
|---|------|----------|------------|---------|------|
|
||||||
|
| 1 | 오늘의 이슈 | 2건 | 신규거래처 2건 표시 | ✅ | ✅ PASS |
|
||||||
|
| 2 | 자금현황 | 0원 | 일일일보 0원, 미수금 9.4억, 미지급금 1.6억 | ✅ | ✅ PASS |
|
||||||
|
| 3 | 현황판 | 7항목 | 수주0, 채권추심7, 안전재고833, 연차0 | ✅ | ✅ PASS |
|
||||||
|
| 4 | 당월예상지출 | 1억 | 매입0, 카드0, 발행어음1억 | ✅ | ✅ PASS |
|
||||||
|
| 5 | 가지급금 | 1,150만 | 카드1,150만, 경조사0, 상품권0, 접대비0 | ✅ | ✅ PASS |
|
||||||
|
| 6 | 접대비 | 10만 | 주말심야0, 기피업종0, 고액결제0, 증빙미비10만1건 | ✅ | ✅ PASS |
|
||||||
|
| 7 | 복리후생비 | 0원 | 4개 리스크 카드 모두 0원 0건 | ✅ | ✅ PASS |
|
||||||
|
| 8 | 미수금 | 9.4억 | 누적9.4억, 당월-533만, 거래처69건, Top3 표시 | ✅ | ✅ PASS |
|
||||||
|
| 9 | 채권추심 | 1.2억 | 추심중4,782만, 법적조치4,463만, 회수2,058만 | ✅ | ✅ PASS |
|
||||||
|
| 10 | 부가세 | 0원 | 매출세액0, 매입세액0, 미발행0건 | ✅ | ✅ PASS |
|
||||||
|
| 11 | 캘린더 | 26일정 | 3월 캘린더 정상, 공휴일/일정/신규업체 표시 | ✅ | ✅ PASS |
|
||||||
|
| 12 | 매출현황 | 1억 | 누적1억343만, 당월715만, 달성률4%, 월별차트/거래처차트 | ✅ | ✅ PASS |
|
||||||
|
| 13 | 당월매출내역 | - | 10건, 합계220만, 거래처별 필터 | ✅ | ✅ PASS |
|
||||||
|
| 14 | 매입현황 | 165만 | 누적165만, 미결제165만, 월별차트/유형별차트 | ✅ | ✅ PASS |
|
||||||
|
| 15 | 당월매입내역 | - | 1건, 165만, 미결제 | ✅ | ✅ PASS |
|
||||||
|
| 16 | 생산현황 | 0공정 | "오늘 등록된 작업 지시가 없습니다" | ✅ | ✅ PASS |
|
||||||
|
| 17 | 출고현황 | 0건 | 7일 이내 0건, 30일 이내 0건 | ✅ | ✅ PASS |
|
||||||
|
| 18 | 미출고내역 | 6건 | 6건 목록, 포트번호/현장명/납기일/남은일 표시 | ✅ | ✅ PASS |
|
||||||
|
| 19 | 시공현황 | 0건 | 시공진행0, 시공완료0 | ✅ | ✅ PASS |
|
||||||
|
| 20 | 근태현황 | 0명 | 출근0, 휴가0, 지각0, 결근0 | ✅ | ✅ PASS |
|
||||||
|
|
||||||
|
### 매출관리 ↔ 대시보드 교차검증
|
||||||
|
|
||||||
|
| 소스 페이지 | 소스 값 | 대시보드 값 | 일치 |
|
||||||
|
|-----------|---------|-----------|------|
|
||||||
|
| 매출관리 > 당월 매출 | 7,150,000원 | 당월 매출 715만 | ✅ |
|
||||||
|
| 매출관리 > 총 매출 | 17,050,000원 | 누적 매출 1억 343만 | ✅ (누적=해당년도) |
|
||||||
|
| 미수금 > 자금현황 | 9억 4,145만 | 미수금 섹션 9억 4,145만 | ✅ |
|
||||||
|
|
||||||
|
### 최종 검수 결론
|
||||||
|
|
||||||
|
- **전체 20개 섹션**: API 연동 확인, 데이터 정상 표시 ✅
|
||||||
|
- **CRUD 검증 (시나리오A)**: 등록→수정→상태변경→삭제 전 사이클 완벽 ✅
|
||||||
|
- **교차 섹션 연동**: 상품권↔가지급금↔접대비 양방향 완벽 ✅
|
||||||
|
- **NavBar ↔ 섹션 일관성**: 모든 NavBar 요약값과 상세 섹션값 일치 ✅
|
||||||
|
- **소스 페이지 ↔ 대시보드 일관성**: 매출관리 등 소스 데이터와 일치 ✅
|
||||||
|
|
||||||
|
**🟢 CEO 대시보드 백엔드 연동 검수 완료. 데이터 인프라 확정.**
|
||||||
@@ -136,15 +136,37 @@ http://localhost:3000/ko/material/stock-status # 🆕 재고현황
|
|||||||
|--------|-----|------|
|
|--------|-----|------|
|
||||||
| **검사관리** | `/ko/quality/inspections` | 🆕 NEW |
|
| **검사관리** | `/ko/quality/inspections` | 🆕 NEW |
|
||||||
| **실적신고관리** | `/ko/quality/performance-reports` | 🆕 NEW |
|
| **실적신고관리** | `/ko/quality/performance-reports` | 🆕 NEW |
|
||||||
|
| **설비 등록대장** | `/ko/quality/equipment` | 🆕 NEW |
|
||||||
|
| **설비 현황** | `/ko/quality/equipment-status` | 🆕 NEW |
|
||||||
|
| **일상점검표** | `/ko/quality/equipment-inspections` | 🆕 NEW |
|
||||||
|
| **수리이력** | `/ko/quality/equipment-repairs` | 🆕 NEW |
|
||||||
|
|
||||||
```
|
```
|
||||||
http://localhost:3000/ko/quality/inspections # 🆕 검사관리
|
http://localhost:3000/ko/quality/inspections # 🆕 검사관리
|
||||||
http://localhost:3000/ko/quality/performance-reports # 🆕 실적신고관리
|
http://localhost:3000/ko/quality/performance-reports # 🆕 실적신고관리
|
||||||
|
http://localhost:3000/ko/quality/equipment # 🆕 설비 등록대장
|
||||||
|
http://localhost:3000/ko/quality/equipment-status # 🆕 설비 현황
|
||||||
|
http://localhost:3000/ko/quality/equipment-inspections # 🆕 일상점검표
|
||||||
|
http://localhost:3000/ko/quality/equipment-repairs # 🆕 수리이력
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🚗 차량/지게차 (Vehicle Management)
|
## 🚗 차량관리 (Vehicle)
|
||||||
|
|
||||||
|
| 페이지 | URL | 상태 |
|
||||||
|
|--------|-----|------|
|
||||||
|
| **법인차량관리** | `/ko/vehicle/corporate-vehicles` | 🆕 NEW |
|
||||||
|
| **차량일지** | `/ko/vehicle/vehicle-logs` | 🆕 NEW |
|
||||||
|
| **정비이력** | `/ko/vehicle/vehicle-maintenance` | 🆕 NEW |
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3000/ko/vehicle/corporate-vehicles # 🆕 법인차량관리
|
||||||
|
http://localhost:3000/ko/vehicle/vehicle-logs # 🆕 차량일지
|
||||||
|
http://localhost:3000/ko/vehicle/vehicle-maintenance # 🆕 정비이력
|
||||||
|
```
|
||||||
|
|
||||||
|
### 이전 차량/지게차 (레거시)
|
||||||
|
|
||||||
| 페이지 | URL | 상태 |
|
| 페이지 | URL | 상태 |
|
||||||
|--------|-----|------|
|
|--------|-----|------|
|
||||||
@@ -401,7 +423,14 @@ http://localhost:3000/ko/outbound/shipments # 🆕 출하관리
|
|||||||
http://localhost:3000/ko/outbound/vehicle-dispatches # 🆕 배차차량관리
|
http://localhost:3000/ko/outbound/vehicle-dispatches # 🆕 배차차량관리
|
||||||
```
|
```
|
||||||
|
|
||||||
### Vehicle Management (차량/지게차)
|
### Vehicle (차량관리)
|
||||||
|
```
|
||||||
|
http://localhost:3000/ko/vehicle/corporate-vehicles # 🆕 법인차량관리
|
||||||
|
http://localhost:3000/ko/vehicle/vehicle-logs # 🆕 차량일지
|
||||||
|
http://localhost:3000/ko/vehicle/vehicle-maintenance # 🆕 정비이력
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vehicle Management (레거시 차량/지게차)
|
||||||
```
|
```
|
||||||
http://localhost:3000/ko/vehicle-management/vehicle # 🆕 차량관리
|
http://localhost:3000/ko/vehicle-management/vehicle # 🆕 차량관리
|
||||||
http://localhost:3000/ko/vehicle-management/vehicle-log # 🆕 차량일지/월간사진기록
|
http://localhost:3000/ko/vehicle-management/vehicle-log # 🆕 차량일지/월간사진기록
|
||||||
@@ -519,12 +548,21 @@ http://localhost:3000/ko/dev/editable-table # Editable Table 테스트
|
|||||||
// Quality (품질관리)
|
// Quality (품질관리)
|
||||||
'/quality/inspections' // 검사관리 (🆕 NEW)
|
'/quality/inspections' // 검사관리 (🆕 NEW)
|
||||||
'/quality/performance-reports' // 실적신고관리 (🆕 NEW)
|
'/quality/performance-reports' // 실적신고관리 (🆕 NEW)
|
||||||
|
'/quality/equipment' // 설비 등록대장 (🆕 NEW)
|
||||||
|
'/quality/equipment-status' // 설비 현황 (🆕 NEW)
|
||||||
|
'/quality/equipment-inspections' // 일상점검표 (🆕 NEW)
|
||||||
|
'/quality/equipment-repairs' // 수리이력 (🆕 NEW)
|
||||||
|
|
||||||
// Outbound (출고관리)
|
// Outbound (출고관리)
|
||||||
'/outbound/shipments' // 출하관리 (🆕 NEW)
|
'/outbound/shipments' // 출하관리 (🆕 NEW)
|
||||||
'/outbound/vehicle-dispatches' // 배차차량관리 (🆕 NEW)
|
'/outbound/vehicle-dispatches' // 배차차량관리 (🆕 NEW)
|
||||||
|
|
||||||
// Vehicle Management (차량/지게차)
|
// Vehicle (차량관리)
|
||||||
|
'/vehicle/corporate-vehicles' // 법인차량관리 (🆕 NEW)
|
||||||
|
'/vehicle/vehicle-logs' // 차량일지 (🆕 NEW)
|
||||||
|
'/vehicle/vehicle-maintenance' // 정비이력 (🆕 NEW)
|
||||||
|
|
||||||
|
// Vehicle Management (레거시 차량/지게차)
|
||||||
'/vehicle-management/vehicle' // 차량관리 (🆕 NEW)
|
'/vehicle-management/vehicle' // 차량관리 (🆕 NEW)
|
||||||
'/vehicle-management/vehicle-log' // 차량일지/월간사진기록 (🆕 NEW)
|
'/vehicle-management/vehicle-log' // 차량일지/월간사진기록 (🆕 NEW)
|
||||||
'/vehicle-management/forklift' // 지게차 관리 (🆕 NEW)
|
'/vehicle-management/forklift' // 지게차 관리 (🆕 NEW)
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
1. [공통 컴포넌트 맵](#1-공통-컴포넌트-맵)
|
1. [공통 컴포넌트 맵](#1-공통-컴포넌트-맵)
|
||||||
2. [검색 모달 (SearchableSelectionModal)](#2-검색-모달)
|
2. [검색 모달 (SearchableSelectionModal)](#2-검색-모달)
|
||||||
3. [리스트 페이지](#3-리스트-페이지)
|
3. [리스트 페이지](#3-리스트-페이지)
|
||||||
4. [상세/폼 페이지](#4-상세폼-페이지)
|
4. [IntegratedListTemplateV2 표준 적용](#4-integratedlisttemplatev2-표준-적용)
|
||||||
5. [API 연동 패턴](#5-api-연동-패턴)
|
5. [상세/폼 페이지](#5-상세폼-페이지)
|
||||||
6. [페이지 라우팅 구조](#6-페이지-라우팅-구조)
|
6. [API 연동 패턴](#6-api-연동-패턴)
|
||||||
|
7. [페이지 라우팅 구조](#7-페이지-라우팅-구조)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -304,7 +305,367 @@ export function MyList() {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 상세/폼 페이지
|
## 4. IntegratedListTemplateV2 표준 적용
|
||||||
|
|
||||||
|
### 개요
|
||||||
|
|
||||||
|
`IntegratedListTemplateV2`는 프로젝트의 표준 리스트 페이지 템플릿으로, 아래 기능을 한 번에 제공한다:
|
||||||
|
- PageLayout + PageHeader (아이콘/제목/설명)
|
||||||
|
- 날짜/검색/버튼 헤더 영역
|
||||||
|
- 통계 카드
|
||||||
|
- 테이블 (체크박스/번호/데이터/작업) + 페이지네이션
|
||||||
|
- 모바일 카드 뷰 자동 전환
|
||||||
|
- 컬럼 설정 (표시/숨기기/리사이즈)
|
||||||
|
|
||||||
|
**위치**: `src/components/templates/IntegratedListTemplateV2.tsx`
|
||||||
|
|
||||||
|
### 🔴 적용 시 필수 체크리스트
|
||||||
|
|
||||||
|
IntegratedListTemplateV2를 사용하는 페이지를 만들거나 리팩토링할 때, **아래 항목을 반드시 확인**한다.
|
||||||
|
|
||||||
|
| # | 항목 | 설명 | 필수 |
|
||||||
|
|---|------|------|:----:|
|
||||||
|
| 1 | **컬럼 설정** | `useColumnSettings` + `ColumnSettingsPopover` + `columnSettings` prop | ✅ |
|
||||||
|
| 2 | **검색** | `searchValue` + `onSearchChange` + `searchPlaceholder` | ✅ |
|
||||||
|
| 3 | **체크박스 선택** | `selectedItems (Set<string>)` + `onToggleSelection` + `onToggleSelectAll` + `getItemId` | ✅ |
|
||||||
|
| 4 | **페이지네이션** | `pagination` (currentPage, totalPages, totalItems, itemsPerPage, onPageChange) | ✅ |
|
||||||
|
| 5 | **모바일 카드** | `renderMobileCard` + `MobileCard` / `InfoField` 사용 | ✅ |
|
||||||
|
| 6 | **테이블 행** | `renderTableRow` (TableRow + TableCell 조합) | ✅ |
|
||||||
|
| 7 | **헤더 레이아웃** | 순서: `[검색] [날짜/연월] --- [액션버튼] [등록버튼]` | ✅ |
|
||||||
|
| 8 | **통계 카드** | `stats` 배열 (label, value, icon, iconColor) | 권장 |
|
||||||
|
| 9 | **테이블 내 필터** | `filterConfig` 통합 필터 사용 (PC: 인라인, 모바일: 바텀시트 자동 분기). `tableHeaderActions`에 Select 직접 넣기 금지 | ✅ |
|
||||||
|
| 10 | **탭** | `tabsContent` (커스텀) 또는 `tabs` + `activeTab` + `onTabChange` | 필요 시 |
|
||||||
|
|
||||||
|
### 컬럼 설정 (필수 패턴)
|
||||||
|
|
||||||
|
**매번 빠뜨리지 않도록 3가지 세트로 기억한다:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1️⃣ Hook 선언
|
||||||
|
import { useColumnSettings } from '@/hooks/useColumnSettings';
|
||||||
|
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
||||||
|
|
||||||
|
const TABLE_COLUMNS: TableColumn[] = [
|
||||||
|
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||||
|
{ key: 'name', label: '이름', copyable: true },
|
||||||
|
// ...
|
||||||
|
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const {
|
||||||
|
visibleColumns, // → tableColumns prop에 전달
|
||||||
|
allColumnsWithVisibility, // → ColumnSettingsPopover에 전달
|
||||||
|
columnWidths, // → columnSettings.columnWidths
|
||||||
|
setColumnWidth, // → columnSettings.onColumnResize
|
||||||
|
toggleColumnVisibility, // → ColumnSettingsPopover.onToggle
|
||||||
|
resetSettings, // → ColumnSettingsPopover.onReset
|
||||||
|
hasHiddenColumns, // → ColumnSettingsPopover.hasHiddenColumns
|
||||||
|
} = useColumnSettings({
|
||||||
|
pageId: 'my-page-id', // Zustand 저장 키 (고유값)
|
||||||
|
columns: TABLE_COLUMNS,
|
||||||
|
alwaysVisibleKeys: ['no', 'name', 'actions'], // 숨기기 불가 컬럼
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 2️⃣ 템플릿에 전달
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
tableColumns={visibleColumns} // ← TABLE_COLUMNS 아닌 visibleColumns!
|
||||||
|
columnSettings={{
|
||||||
|
columnWidths,
|
||||||
|
onColumnResize: setColumnWidth,
|
||||||
|
settingsPopover: (
|
||||||
|
<ColumnSettingsPopover
|
||||||
|
columns={allColumnsWithVisibility}
|
||||||
|
onToggle={toggleColumnVisibility}
|
||||||
|
onReset={resetSettings}
|
||||||
|
hasHiddenColumns={hasHiddenColumns}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 헤더 레이아웃 순서
|
||||||
|
|
||||||
|
표준 레이아웃은 아래 순서를 따른다:
|
||||||
|
|
||||||
|
```
|
||||||
|
[아이콘] 페이지 제목
|
||||||
|
설명 텍스트
|
||||||
|
|
||||||
|
[검색창] [날짜/연월 셀렉트] --- [액션버튼들] [+ 등록 버튼]
|
||||||
|
|
||||||
|
[탭: 목록 | 설정] (tabsContent, 필요 시)
|
||||||
|
|
||||||
|
[통계카드 ...] (stats)
|
||||||
|
|
||||||
|
[전체 N건 | N개 선택됨] [부서 필터] [상태 필터] [컬럼 설정] (tableHeaderActions)
|
||||||
|
[테이블]
|
||||||
|
[페이지네이션]
|
||||||
|
```
|
||||||
|
|
||||||
|
**날짜 대신 연월 셀렉트가 필요한 경우:**
|
||||||
|
```typescript
|
||||||
|
dateRangeSelector={{
|
||||||
|
enabled: true,
|
||||||
|
hideDateInputs: true, // 날짜 입력 숨김
|
||||||
|
showPresets: false, // 프리셋 버튼 숨김
|
||||||
|
extraActions: ( // 대신 연월 셀렉트 배치
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={String(year)} onValueChange={...}>...</Select>
|
||||||
|
<Select value={String(month)} onValueChange={...}>...</Select>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔴 테이블 내 필터 — filterConfig 통합 방식 (필수)
|
||||||
|
|
||||||
|
테이블 카드 내부 필터는 **반드시 `filterConfig` 통합 필터 시스템**을 사용한다.
|
||||||
|
- PC(xl 이상): 인라인 Select로 자동 렌더링
|
||||||
|
- 모바일/태블릿(xl 미만): 바텀시트(`MobileFilter`)로 자동 분기
|
||||||
|
|
||||||
|
**❌ 금지 패턴**: `tableHeaderActions`에 직접 Select를 넣으면 **모바일에서 필터가 보이지 않는다**.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
IntegratedListTemplateV2,
|
||||||
|
type TableColumn,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
type FilterValues,
|
||||||
|
} from '@/components/templates/IntegratedListTemplateV2';
|
||||||
|
|
||||||
|
// 1️⃣ filterConfig 정의
|
||||||
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
|
{
|
||||||
|
key: 'department',
|
||||||
|
label: '부서',
|
||||||
|
type: 'single',
|
||||||
|
options: departments.map(d => ({ value: d, label: d })),
|
||||||
|
allOptionLabel: '전체 부서',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
label: '상태',
|
||||||
|
type: 'single',
|
||||||
|
options: [
|
||||||
|
{ value: 'draft', label: '작성중' },
|
||||||
|
{ value: 'confirmed', label: '확정' },
|
||||||
|
],
|
||||||
|
allOptionLabel: '전체 상태',
|
||||||
|
},
|
||||||
|
], [departments]);
|
||||||
|
|
||||||
|
// 2️⃣ filterValues 상태 연결
|
||||||
|
const filterValues: FilterValues = useMemo(() => ({
|
||||||
|
department: filterDepartment,
|
||||||
|
status: filterStatus,
|
||||||
|
}), [filterDepartment, filterStatus]);
|
||||||
|
|
||||||
|
const handleFilterChange = useCallback((key: string, value: string | string[]) => {
|
||||||
|
if (key === 'department') { setFilterDepartment(value as string); setCurrentPage(1); }
|
||||||
|
if (key === 'status') { setFilterStatus(value as string); setCurrentPage(1); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFilterReset = useCallback(() => {
|
||||||
|
setFilterDepartment('all');
|
||||||
|
setFilterStatus('all');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 3️⃣ tableHeaderActions에는 필터 외 액션만 (엑셀 등)
|
||||||
|
const tableHeaderActions = useMemo(() => (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||||
|
<Download className="mr-1 h-4 w-4" />
|
||||||
|
엑셀
|
||||||
|
</Button>
|
||||||
|
), [handleExcelDownload]);
|
||||||
|
|
||||||
|
// 4️⃣ 템플릿에 전달
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
filterConfig={filterConfig}
|
||||||
|
filterValues={filterValues}
|
||||||
|
onFilterChange={handleFilterChange}
|
||||||
|
onFilterReset={handleFilterReset}
|
||||||
|
filterTitle="검색 필터"
|
||||||
|
tableHeaderActions={tableHeaderActions} // 엑셀 등 비필터 액션만
|
||||||
|
// ...
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
| prop | 역할 | 필수 |
|
||||||
|
|------|------|:----:|
|
||||||
|
| `filterConfig` | 필터 필드 정의 (key, label, type, options) | ✅ |
|
||||||
|
| `filterValues` | 현재 필터 상태 | ✅ |
|
||||||
|
| `onFilterChange` | 필터 값 변경 핸들러 | ✅ |
|
||||||
|
| `onFilterReset` | 필터 초기화 핸들러 | ✅ |
|
||||||
|
| `filterTitle` | 모바일 바텀시트 타이틀 (기본: "검색 필터") | 권장 |
|
||||||
|
| `tableHeaderActions` | 필터 외 액션 (엑셀 버튼 등) | 필요 시 |
|
||||||
|
|
||||||
|
### 모바일 카드 (renderMobileCard)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||||
|
|
||||||
|
const renderMobileCard = useCallback((
|
||||||
|
item: MyItem,
|
||||||
|
_index: number,
|
||||||
|
_globalIndex: number,
|
||||||
|
isSelected: boolean,
|
||||||
|
onToggle: () => void,
|
||||||
|
) => (
|
||||||
|
<MobileCard
|
||||||
|
key={item.id}
|
||||||
|
title={item.name}
|
||||||
|
subtitle={item.department || '-'}
|
||||||
|
headerBadges={[
|
||||||
|
{ text: STATUS_LABELS[item.status], variant: STATUS_VARIANTS[item.status] },
|
||||||
|
]}
|
||||||
|
infoGrid={[
|
||||||
|
<InfoField key="amount" label="금액" value={formatCurrency(item.amount)} />,
|
||||||
|
<InfoField key="date" label="날짜" value={item.date} />,
|
||||||
|
]}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggleSelection={onToggle}
|
||||||
|
onClick={() => handleDetailOpen(item.id)}
|
||||||
|
/>
|
||||||
|
), []);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 체크박스 선택 (Set\<string\>)
|
||||||
|
|
||||||
|
IntegratedListTemplateV2는 **문자열 ID** (`Set<string>`)를 요구한다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 올바른 패턴
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleSelection = useCallback((id: string) => {
|
||||||
|
setSelectedIds(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleSelectAll = useCallback(() => {
|
||||||
|
setSelectedIds(prev =>
|
||||||
|
prev.size === data.length
|
||||||
|
? new Set()
|
||||||
|
: new Set(data.map(item => String(item.id)))
|
||||||
|
);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// 사용
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
selectedItems={selectedIds}
|
||||||
|
onToggleSelection={toggleSelection}
|
||||||
|
onToggleSelectAll={toggleSelectAll}
|
||||||
|
getItemId={(item) => String(item.id)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 전체 스켈레톤 예제
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { IntegratedListTemplateV2, type TableColumn } from '@/components/templates/IntegratedListTemplateV2';
|
||||||
|
import { useColumnSettings } from '@/hooks/useColumnSettings';
|
||||||
|
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
||||||
|
import { MobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||||
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { MyIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
const TABLE_COLUMNS: TableColumn[] = [
|
||||||
|
{ key: 'no', label: '번호', className: 'text-center w-[60px]' },
|
||||||
|
{ key: 'name', label: '이름', copyable: true },
|
||||||
|
{ key: 'status', label: '상태', className: 'text-center w-[80px]' },
|
||||||
|
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MyListPage() {
|
||||||
|
// 컬럼 설정 (필수)
|
||||||
|
const {
|
||||||
|
visibleColumns, allColumnsWithVisibility, columnWidths,
|
||||||
|
setColumnWidth, toggleColumnVisibility, resetSettings, hasHiddenColumns,
|
||||||
|
} = useColumnSettings({
|
||||||
|
pageId: 'my-page',
|
||||||
|
columns: TABLE_COLUMNS,
|
||||||
|
alwaysVisibleKeys: ['no', 'name', 'actions'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 선택 (Set<string>)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
|
// ... toggleSelection, toggleSelectAll 구현
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntegratedListTemplateV2<MyItem>
|
||||||
|
// 헤더
|
||||||
|
title="페이지 제목"
|
||||||
|
description="설명"
|
||||||
|
icon={MyIcon}
|
||||||
|
|
||||||
|
// 헤더 액션
|
||||||
|
headerActions={<Button>액션</Button>}
|
||||||
|
createButton={{ label: '등록', onClick: handleCreate }}
|
||||||
|
|
||||||
|
// 검색
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="검색..."
|
||||||
|
|
||||||
|
// 통계
|
||||||
|
stats={[{ label: '전체', value: totalCount, icon: Users, iconColor: 'text-blue-600' }]}
|
||||||
|
|
||||||
|
// 테이블 필터
|
||||||
|
tableHeaderActions={filterNode}
|
||||||
|
|
||||||
|
// 테이블 + 컬럼 설정
|
||||||
|
tableColumns={visibleColumns}
|
||||||
|
columnSettings={{
|
||||||
|
columnWidths,
|
||||||
|
onColumnResize: setColumnWidth,
|
||||||
|
settingsPopover: (
|
||||||
|
<ColumnSettingsPopover
|
||||||
|
columns={allColumnsWithVisibility}
|
||||||
|
onToggle={toggleColumnVisibility}
|
||||||
|
onReset={resetSettings}
|
||||||
|
hasHiddenColumns={hasHiddenColumns}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
data={items}
|
||||||
|
selectedItems={selectedIds}
|
||||||
|
onToggleSelection={toggleSelection}
|
||||||
|
onToggleSelectAll={toggleSelectAll}
|
||||||
|
getItemId={(item) => String(item.id)}
|
||||||
|
|
||||||
|
// 렌더링
|
||||||
|
renderTableRow={renderTableRow}
|
||||||
|
renderMobileCard={renderMobileCard}
|
||||||
|
|
||||||
|
// 페이지네이션
|
||||||
|
pagination={{
|
||||||
|
currentPage, totalPages, totalItems: totalCount,
|
||||||
|
itemsPerPage: PAGE_SIZE, onPageChange: setCurrentPage,
|
||||||
|
}}
|
||||||
|
|
||||||
|
// 로딩
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 상세/폼 페이지
|
||||||
|
|
||||||
### 표준 구조
|
### 표준 구조
|
||||||
|
|
||||||
@@ -405,18 +766,37 @@ export function MyDetail({ id, mode }: DetailProps) {
|
|||||||
### 상세/폼 페이지 공통 규칙
|
### 상세/폼 페이지 공통 규칙
|
||||||
|
|
||||||
- **모드**: `view` | `edit` | `new` 3가지
|
- **모드**: `view` | `edit` | `new` 3가지
|
||||||
|
- **라우팅**: `?mode=new` / `?mode=edit` 쿼리파라미터 사용 (별도 `/new`, `/edit` 경로 금지)
|
||||||
|
- **page.tsx 분기**: 목록 page.tsx에서 `searchParams.get('mode')` 로 등록 폼 분기
|
||||||
- **Hook 규칙**: 모든 hook은 최상단, 조건부 return은 그 아래
|
- **Hook 규칙**: 모든 hook은 최상단, 조건부 return은 그 아래
|
||||||
- **레이아웃**: `Card > CardHeader + CardContent` 섹션 단위
|
- **레이아웃**: `Card > CardHeader + CardContent` 섹션 단위
|
||||||
- **필드 그리드**: `grid grid-cols-1 md:grid-cols-2 gap-4`
|
- **필드 그리드**: `grid grid-cols-1 md:grid-cols-2 gap-4`
|
||||||
- **disabled**: view 모드에서 모든 입력 비활성화
|
- **disabled**: view 모드에서 모든 입력 비활성화
|
||||||
- **알림**: `toast.success()` / `toast.error()` (sonner)
|
- **알림**: `toast.success()` / `toast.error()` (sonner)
|
||||||
- **네비게이션**: `router.back()` 또는 `router.push()`
|
|
||||||
- **로딩**: Skeleton 컴포넌트 사용
|
- **로딩**: Skeleton 컴포넌트 사용
|
||||||
- **Select 버그 대응**: `<Select key={...}>` 패턴 (CLAUDE.md 참조)
|
- **Select 버그 대응**: `<Select key={...}>` 패턴 (CLAUDE.md 참조)
|
||||||
|
|
||||||
|
#### 헤더 배치 표준
|
||||||
|
| 위치 | 요소 |
|
||||||
|
|------|------|
|
||||||
|
| 상단 좌측 | 페이지 제목 (`<h1>`) |
|
||||||
|
| 상단 우측 | `← 목록으로` (Button variant="link") |
|
||||||
|
|
||||||
|
#### 하단 Sticky 액션 바
|
||||||
|
Card 내부가 아닌 **sticky bottom bar**로 버튼 배치. 취소 좌측, 주요 액션 우측.
|
||||||
|
|
||||||
|
| 모드 | 좌측 | 우측 |
|
||||||
|
|------|------|------|
|
||||||
|
| 등록 (new) | `X 취소` | `💾 저장` |
|
||||||
|
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
|
||||||
|
| 수정 (edit) | `X 취소` | `💾 저장` |
|
||||||
|
|
||||||
|
- 아이콘 포함: 취소(`X`), 저장(`Save`), 수정(`Pencil`)
|
||||||
|
- 상세(view) "취소"는 목록 이동, "수정"은 `?mode=edit` 전환
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. API 연동 패턴
|
## 6. API 연동 패턴
|
||||||
|
|
||||||
### Server Action 파일 구조
|
### Server Action 파일 구조
|
||||||
|
|
||||||
@@ -478,7 +858,7 @@ const handleFetchData = useCallback(async (query: string) => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 페이지 라우팅 구조
|
## 7. 페이지 라우팅 구조
|
||||||
|
|
||||||
```
|
```
|
||||||
src/app/[locale]/(protected)/[domain]/
|
src/app/[locale]/(protected)/[domain]/
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# 출하/배차 API 연동 — 배차 다중행 + 차량관리 + 출고관리
|
||||||
|
|
||||||
|
> **작업일**: 2026-03-03 ~ 03-07
|
||||||
|
> **상태**: ✅ 완료
|
||||||
|
> **커밋**: 83a23701, 1d380578, 5ff5093d, f653960a, a4f99ae3, 03d129c3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
출하/배차 관련 3개 모듈의 API 연동 및 레이아웃 개선.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 배차정보 다중 행 API 연동
|
||||||
|
|
||||||
|
기존 단일 배차 → `vehicle_dispatches` 배열 지원.
|
||||||
|
|
||||||
|
- [x] `ShipmentApiData`에 `vehicle_dispatches` 배열 필드 추가
|
||||||
|
- [x] `transformApiToDetail()` — vehicle_dispatches 배열 매핑
|
||||||
|
- [x] `transformCreateFormToApi()` — 폼 vehicleDispatches → API vehicle_dispatches 변환
|
||||||
|
- [x] `transformEditFormToApi()` — 수정 시 동일 변환
|
||||||
|
- [x] `transformApiToListItem()` — 첫 번째 배차의 arrival_datetime 목록에 표시
|
||||||
|
- [x] 레거시 단일 배차 필드 하위호환 유지
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/outbound/ShipmentManagement/actions.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 배차차량관리 Mock→API 전환
|
||||||
|
|
||||||
|
- [x] `executePaginatedAction` + `buildApiUrl` 패턴 적용
|
||||||
|
- [x] `transformToListItem()` — snake_case → camelCase 목록 변환
|
||||||
|
- [x] `transformToDetail()` — snake_case → camelCase 상세 변환
|
||||||
|
- [x] 쿼리 파라미터: `search`, `status`, `start_date`, `end_date`, `page`, `per_page`
|
||||||
|
- [x] options/shipment 관계 데이터 중첩 API 응답에서 추출
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/outbound/VehicleDispatchManagement/actions.ts` (+207/-207)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 출고관리 목록 필드 매핑
|
||||||
|
|
||||||
|
- [x] 5개 필드 API 매핑 추가: `writer_name`, `writer_id`, `delivery_date` 등
|
||||||
|
- [x] `OrderInfoApiData` 타입으로 주문 연결 정보 처리
|
||||||
|
- [x] `transformApiToListItem()` 수신자/수신주소/수신처/작성자/출고일 반영
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/outbound/ShipmentManagement/actions.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 배차 상세/수정 레이아웃
|
||||||
|
|
||||||
|
- [x] 기본정보 그리드: 1열 → 2×4열 레이아웃 개선
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx`
|
||||||
|
- `src/components/outbound/ShipmentManagement/ShipmentEdit.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 출하관리 캘린더
|
||||||
|
|
||||||
|
- [x] 기본 뷰: day → week-time 변경
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/outbound/ShipmentManagement/ShipmentList.tsx`
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# 생산지시 API 연동 + 작업자 화면 + 중간검사
|
||||||
|
|
||||||
|
> **작업일**: 2026-03-01 ~ 03-07
|
||||||
|
> **상태**: ✅ 완료
|
||||||
|
> **커밋**: fa7efb7b, 8b6da749, 4331b84a, 0166601b, 8bcabafd, 2a2a356f, b45c35a5, 0b81e9c1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
생산지시(ProductionOrders) 목록/상세 페이지를 Mock→API 전환하고,
|
||||||
|
작업자 화면의 중간검사 입력 모달과 자재투입 모달을 대폭 개선한 작업.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 생산지시 목록/상세 API 연동
|
||||||
|
|
||||||
|
- [x] Mock → API 전환 (`executePaginatedAction` + `buildApiUrl` 패턴)
|
||||||
|
- [x] 서버사이드 페이지네이션 + 동적 탭 카운트 (stats API)
|
||||||
|
- [x] WorkOrder 상태 배지 6단계: 미배정 → 배정 → 작업중 → 검사 → 완료 → 출하
|
||||||
|
- [x] BOM null 상태 처리
|
||||||
|
- [x] PO 번호 = 생산지시 번호 매핑 (별도 PO 번호 필드 불필요)
|
||||||
|
- [x] `clientSideFiltering: false` (서버사이드 처리)
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/production/ProductionOrders/actions.ts` — 서버 액션 (getProductionOrders, getProductionOrderStats, getProductionOrderDetail)
|
||||||
|
- `src/components/production/ProductionOrders/types.ts` — API/프론트엔드 타입 정의
|
||||||
|
- `src/app/[locale]/(protected)/production-orders/page.tsx` — 목록 뷰
|
||||||
|
- `src/app/[locale]/(protected)/production-orders/[id]/page.tsx` — 상세 뷰
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 절곡 중간검사 입력 모달 (InspectionInputModal)
|
||||||
|
|
||||||
|
- [x] 7개 제품 항목 통합 폼
|
||||||
|
- [x] 제품 ID 자동 매칭 (3단계): 정규화 → 키워드 → 인덱스 fallback
|
||||||
|
- [x] cellValues 구조: `{bending_state, length, width, spacing}`
|
||||||
|
- [x] PO 단위 데이터 공유 모델: 동일 PO 내 모든 아이템이 inspection_data 공유
|
||||||
|
- [x] 데이터 로딩: bending 공정 아이템 중 inspection_data 보유 시 전체 적용
|
||||||
|
- [x] 데이터 저장: 중간검사 완료 시 모든 workItem에 동기화
|
||||||
|
|
||||||
|
### 제품 ID 매칭 전략 (bending/utils.ts)
|
||||||
|
```
|
||||||
|
1순위: 정규화 후 정확 매치 (대소문자/공백/특수문자 제거)
|
||||||
|
2순위: 키워드 포함 검색
|
||||||
|
3순위: 인덱스 기반 fallback
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/production/WorkerScreen/InspectionInputModal.tsx` (+396)
|
||||||
|
- `src/components/production/WorkOrders/documents/TemplateInspectionContent.tsx` (신규, +118)
|
||||||
|
- `src/components/production/WorkOrders/documents/bending/utils.ts` (신규, +60)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 자재투입 모달 (MaterialInputModal)
|
||||||
|
|
||||||
|
- [x] 동일 자재 다중 BOM 그룹 LOT 독립 관리
|
||||||
|
- [x] `bomGroupKey = ${item_id}-${category}-${partType}` 그룹핑
|
||||||
|
- [x] 카테고리 정렬 순서:
|
||||||
|
1. 가이드레일
|
||||||
|
2. 하단마감재
|
||||||
|
3. 셔터박스
|
||||||
|
4. 연기차단재
|
||||||
|
- [x] FIFO 자동충전 시 그룹 간 물리적 LOT 가용량 추적
|
||||||
|
- [x] 번호 배지 (①②③) + partType 배지
|
||||||
|
- [x] `allGroupsFulfilled` 조건으로 입력 버튼 활성화 제어
|
||||||
|
- [x] 그룹별 독립 전송: `bom_group_key` + `replace` 모드
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/production/WorkerScreen/MaterialInputModal.tsx` (+356)
|
||||||
|
- `src/components/production/WorkerScreen/actions.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 공정 단계 검사범위 설정 (InspectionScope) — 신규
|
||||||
|
|
||||||
|
- [x] 전수검사 / 샘플링 / 그룹 3가지 타입
|
||||||
|
- [x] 샘플링 시 샘플 수(n) 입력 지원
|
||||||
|
- [x] StepForm 컴포넌트에 UI 추가
|
||||||
|
- [x] options JSON으로 API 저장
|
||||||
|
|
||||||
|
### 타입 정의
|
||||||
|
```typescript
|
||||||
|
type InspectionScopeType = 'FULL' | 'SAMPLING' | 'GROUP';
|
||||||
|
|
||||||
|
interface InspectionScope {
|
||||||
|
type: InspectionScopeType;
|
||||||
|
sampleSize?: number; // SAMPLING 타입일 때만
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/process-management/StepForm.tsx`
|
||||||
|
- `src/components/process-management/actions.ts`
|
||||||
|
- `src/types/process.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 기타 개선
|
||||||
|
|
||||||
|
- [x] 작업자 화면 제품명: productCode만 표시 (간소화)
|
||||||
|
- [x] 작업자 화면 하드코딩 도면 이미지 영역 제거
|
||||||
|
- [x] BOM 공정 분류 접이식 카드 UI
|
||||||
|
- [x] TemplateInspectionContent: products 배열 → cellValues 자동 매핑
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
# 절곡품 모듈 구현 계획서
|
||||||
|
|
||||||
|
> 버디(경동기업 ERP) 절곡품 메뉴 분석 기반 SAM ERP 프론트엔드 구현 계획
|
||||||
|
> 작성일: 2026-03-13 | 백엔드 API 작업 진행 중
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 전체 개요
|
||||||
|
|
||||||
|
### 버디 절곡품 메뉴 구조 (6개 하위 페이지)
|
||||||
|
|
||||||
|
| # | 메뉴명 | 버디 URL | 데이터 건수 | 핵심 기능 |
|
||||||
|
|---|--------|---------|-----------|---------|
|
||||||
|
| 1 | 절곡 바라시 기초자료 | `/bending/list.php` | 265건 | 절곡 형상 마스터 + 그리기 도구 |
|
||||||
|
| 2 | 재고생산/작업일지/중간검사성적서 | `/lot/list.php` | 201건 | LOT 관리 + 중간검사 PDF |
|
||||||
|
| 3 | 가이드레일 | `/guiderail/list.php` | 20건 | 제품 설계 + 전개도 + 작업지시서 |
|
||||||
|
| 4 | 케이스 (셔터박스) | `/shutterbox/list.php` | 30건 | 셔터박스 설계 + 전개도 + 작업지시서 |
|
||||||
|
| 5 | 하단마감재 | `/bottombar/list.php` | 11건 | 마감재 설계 + 작업지시서 |
|
||||||
|
| 6 | 절곡 재고현황 | `/lot/list_stock.php` | 집계 | 재고 요약 + 그룹별 현황 |
|
||||||
|
|
||||||
|
### SAM 라우트 구조 (신규)
|
||||||
|
```
|
||||||
|
src/app/[locale]/(protected)/production/bending/
|
||||||
|
├── page.tsx ← 절곡 바라시 기초자료
|
||||||
|
├── lot/
|
||||||
|
│ └── page.tsx ← 재고생산/작업일지/중간검사성적서
|
||||||
|
├── guiderail/
|
||||||
|
│ └── page.tsx ← 가이드레일
|
||||||
|
├── shutterbox/
|
||||||
|
│ └── page.tsx ← 케이스(셔터박스)
|
||||||
|
├── bottombar/
|
||||||
|
│ └── page.tsx ← 하단마감재
|
||||||
|
└── stock/
|
||||||
|
└── page.tsx ← 절곡 재고현황
|
||||||
|
```
|
||||||
|
|
||||||
|
### 컴포넌트 구조
|
||||||
|
```
|
||||||
|
src/components/production/bending/
|
||||||
|
├── BendingMasterList.tsx ← 바라시 기초자료 목록
|
||||||
|
├── BendingMasterForm.tsx ← 바라시 기초자료 등록/수정 (모달)
|
||||||
|
├── BendingLotList.tsx ← LOT 목록
|
||||||
|
├── BendingLotForm.tsx ← LOT 등록/수정 (모달)
|
||||||
|
├── GuiderailList.tsx ← 가이드레일 목록
|
||||||
|
├── GuiderailForm.tsx ← 가이드레일 등록/수정 (모달)
|
||||||
|
├── ShutterboxList.tsx ← 셔터박스 목록
|
||||||
|
├── ShutterboxForm.tsx ← 셔터박스 등록/수정 (모달)
|
||||||
|
├── BottombarList.tsx ← 하단마감재 목록
|
||||||
|
├── BottombarForm.tsx ← 하단마감재 등록/수정 (모달)
|
||||||
|
├── BendingStockSummary.tsx ← 재고 요약 카드
|
||||||
|
├── BendingStockTable.tsx ← 재고 현황 테이블
|
||||||
|
├── WorkOrderViewer.tsx ← 작업지시서 보기 (공통)
|
||||||
|
├── BlueprintImageManager.tsx ← 결합형태 이미지 관리 (공통)
|
||||||
|
├── actions.ts ← Server Actions
|
||||||
|
└── types.ts ← 타입 정의
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔄 기존 재활용 컴포넌트 (신규 생성 불필요)
|
||||||
|
|
||||||
|
| 기존 컴포넌트 | 경로 | 재활용 용도 |
|
||||||
|
|--------------|------|-----------|
|
||||||
|
| **DrawingCanvas** | `src/components/items/DrawingCanvas.tsx` | 절곡 형상 그리기 도구 (그대로 사용) |
|
||||||
|
| **BendingDiagramSection** | `src/components/items/ItemForm/BendingDiagramSection.tsx` | 전개도 파일 업로드/그리기/치수 테이블 UI 참조 |
|
||||||
|
| **BendingPartForm** | `src/components/items/ItemForm/forms/parts/BendingPartForm.tsx` | 품목명/종류/재질/품목코드 로직 참조 |
|
||||||
|
| **bending/types.ts** | `src/components/production/WorkOrders/documents/bending/types.ts` | 절곡 작업일지 타입 (GuideRailTypeData, ShutterBoxData 등) |
|
||||||
|
|
||||||
|
**DrawingCanvas 기능 현황** (품목관리에서 이미 사용 중):
|
||||||
|
- Canvas 600×400 (반응형 스케일)
|
||||||
|
- 도구: 펜, 직선, 사각형, 원, 텍스트, 지우개
|
||||||
|
- 색상 팔레트 10색 + 선 두께 조절 (1~20px)
|
||||||
|
- Undo, 전체 지우기, 히스토리 관리
|
||||||
|
- 초기 이미지 로드 (기존 이미지 편집 가능)
|
||||||
|
- PNG data URL로 저장 → onSave 콜백
|
||||||
|
- Dialog 모달로 래핑됨 (open/onOpenChange props)
|
||||||
|
|
||||||
|
**BendingDiagramSection 기능 현황** (품목 등록 폼에서 사용 중):
|
||||||
|
- 입력방식 선택: 파일 업로드 / 드로잉 (DrawingCanvas 연동)
|
||||||
|
- FileDropzone으로 이미지/PDF 업로드 + 미리보기
|
||||||
|
- 기존 파일 표시/다운로드/삭제 (수정 모드)
|
||||||
|
- 전개도 상세 입력 테이블: 번호, 입력값, 연신율, 계산값, 음영, A각
|
||||||
|
- 폭 합계 자동 계산 → setValue로 폼 필드 연동
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 페이지별 상세 분석 및 구현 계획
|
||||||
|
|
||||||
|
### 2-1. 절곡 바라시 기초자료 (Bending Master)
|
||||||
|
|
||||||
|
#### 목록 페이지
|
||||||
|
**필터 영역:**
|
||||||
|
| 필터명 | 타입 | 옵션 | 기본값 |
|
||||||
|
|--------|------|------|--------|
|
||||||
|
| 대분류 | 라디오 버튼 (토글) | 전체 / 스크린 / 철재 | 전체 |
|
||||||
|
| 인정/비인정 | 라디오 버튼 (토글) | 전체 / 인정 / 비인정 | 전체 |
|
||||||
|
| 중분류 (절곡물 분류) | Select | 가이드레일, 케이스, 하단마감재, 마구리, L-BAR, 보강평철, 케이스용 연기차단재, 가이드레일용 연기차단재 | (중분류) |
|
||||||
|
| 품명 | Select (동적) | 중분류 선택에 따라 변경 (~78개 옵션) | (품명) |
|
||||||
|
| 키워드 검색 | 텍스트 입력 | - | - |
|
||||||
|
|
||||||
|
**테이블 컬럼:**
|
||||||
|
| 순서 | 컬럼명 | 설명 | 정렬 |
|
||||||
|
|------|--------|------|------|
|
||||||
|
| 1 | NO | 순번 | ✅ |
|
||||||
|
| 2 | 등록일 | yyyy-MM-dd | ✅ |
|
||||||
|
| 3 | 대분류 | 스크린/철재 | ✅ |
|
||||||
|
| 4 | 인정/비인정 | 인정/비인정 | ✅ |
|
||||||
|
| 5 | 절곡물 분류 | 중분류 카테고리 | ✅ |
|
||||||
|
| 6 | 품명 | 링크 (상세 팝업) | ✅ |
|
||||||
|
| 7 | 규격(가로*세로) | 치수 | ✅ |
|
||||||
|
| 8 | 이미지(형상) | 썸네일 이미지 | - |
|
||||||
|
| 9 | 재질 | EGI 1.55T, SUS 1.2T 등 | ✅ |
|
||||||
|
| 10 | 폭 합계 | 숫자 | ✅ |
|
||||||
|
| 11 | 절곡회수 | 숫자 (색상 강조) | ✅ |
|
||||||
|
| 12 | 역방향(음영) | 숫자 | ✅ |
|
||||||
|
| 13 | A각 수 | 숫자 | ✅ |
|
||||||
|
| 14 | 폭합 | 숫자 | ✅ |
|
||||||
|
| 15 | 작성 | 작성자 | ✅ |
|
||||||
|
| 16 | 검색어 | 품목 검색 키워드 | ✅ |
|
||||||
|
| 17 | 비고 | 메모 | ✅ |
|
||||||
|
|
||||||
|
**액션 버튼:**
|
||||||
|
- `신규` → 등록 폼 팝업
|
||||||
|
- `절곡 모델설정 이동` → 별도 설정 페이지
|
||||||
|
- `절곡 BOM 이동` → BOM 관리 페이지
|
||||||
|
|
||||||
|
**페이지네이션:** 50/100/200/500/1,000/2,000 entries
|
||||||
|
|
||||||
|
#### 등록/수정 폼 (모달/팝업)
|
||||||
|
**폼 필드:**
|
||||||
|
| 필드명 | 타입 | 필수 | 설명 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| 등록일 | DatePicker | ✅ | 기본값: 오늘 |
|
||||||
|
| 형태 | 라디오 | ✅ | 스크린/철재 |
|
||||||
|
| 인정/비인정 | 라디오 | ✅ | 인정/비인정 |
|
||||||
|
| 절곡품 그룹 | Select | ✅ | 8개 옵션 |
|
||||||
|
| 품명 | 텍스트 입력 | ✅ | |
|
||||||
|
| 규격(가로*세로) | 텍스트 입력 | | |
|
||||||
|
| 재질 | Select | | EGI 1.15T, EGI 1.55T, SUS 1.2T, SUS 1.5T |
|
||||||
|
| 점검구 방향 | Select | | 양면/후면/밑면 점검구 (케이스 부품 전용) |
|
||||||
|
| 케이스 너비 | 숫자 입력 | | 케이스 부품 전용 |
|
||||||
|
| 케이스 높이 | 숫자 입력 | | 케이스 부품 전용 |
|
||||||
|
| 전면부 밑 치수 | 숫자 입력 | | 케이스 부품 전용 |
|
||||||
|
| 레일폭 | 숫자 입력 | | 케이스 부품 전용 |
|
||||||
|
| 작성자 | 텍스트 (자동) | ✅ | 로그인 사용자 |
|
||||||
|
| 품목 검색어 | 텍스트 입력 | | |
|
||||||
|
| 비고 | 텍스트 입력 | | |
|
||||||
|
|
||||||
|
**절곡 형상 입력 테이블 (동적 행):**
|
||||||
|
| 행 필드 | 설명 |
|
||||||
|
|---------|------|
|
||||||
|
| 번호 | 행 순번 (+/- 버튼) |
|
||||||
|
| 입력 | 폭 치수 입력 (노란 배경) |
|
||||||
|
| 연신율 | 연신율 값 |
|
||||||
|
| 연신율계산 후 | 자동 계산 (읽기전용, 회색) |
|
||||||
|
| 합계 | 누적 합계 (주황 배경) |
|
||||||
|
| 음영 | 체크박스 (역방향 표시) |
|
||||||
|
| A각 표시 | 체크박스 |
|
||||||
|
|
||||||
|
**하단 버튼:**
|
||||||
|
- `모든칸 비우기` | `마지막 열추가` | `마지막 열삭제`
|
||||||
|
|
||||||
|
**우측 패널:**
|
||||||
|
- `그리기` 버튼 → Canvas 기반 절곡 형상 드로잉
|
||||||
|
- 이미지 붙여넣기 (Ctrl+V) 영역
|
||||||
|
- 조회 모드에서는 그리기 비활성화
|
||||||
|
|
||||||
|
**상세 조회 모드 추가 버튼:**
|
||||||
|
- `수정` | `복사` | `삭제` | `닫기`
|
||||||
|
|
||||||
|
#### 구현 포인트
|
||||||
|
- **그리기 도구**: ✅ 기존 `DrawingCanvas` 재활용 (신규 구현 불필요)
|
||||||
|
- **전개도 섹션**: ✅ `BendingDiagramSection` 패턴 참조 (파일업로드/그리기 전환, 치수 테이블)
|
||||||
|
- **동적 행 관리**: 열 추가/삭제 + 자동 합계 계산 (BendingDiagramSection 로직 참조)
|
||||||
|
- **조건부 필드**: 절곡품 그룹이 "케이스"일 때만 케이스 관련 필드 표시
|
||||||
|
- **이미지 붙여넣기**: Clipboard API 활용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-2. 재고생산/작업일지/중간검사성적서 (Bending LOT)
|
||||||
|
|
||||||
|
#### 목록 페이지
|
||||||
|
**필터 영역:**
|
||||||
|
| 필터명 | 타입 | 옵션 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 품목명 | Select | 가이드레일(벽면형), 가이드레일(측면형), 연기차단재, 하단마감재(스크린), 하단마감재(철재), L-Bar, 케이스 |
|
||||||
|
| 종류명 | Select | 화이바원단, SUS(마감), SUS(마감)2, EGI(마감), 스크린용, D형, C형, 본체, 본체(철재), 후면코너부, 린텔부, 점검구, 전면부 |
|
||||||
|
| 모양&길이 | Select | W50×3000~4000, W80×3000~4000, 1219~4300 등 |
|
||||||
|
| 키워드 검색 | 텍스트 입력 | |
|
||||||
|
|
||||||
|
**테이블 컬럼:**
|
||||||
|
| 순서 | 컬럼명 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 1 | 번호 | 순번 |
|
||||||
|
| 2 | 등록일 | yyyy-MM-dd |
|
||||||
|
| 3 | 원자재 LOT | LOT 번호 (링크, 파란색) |
|
||||||
|
| 4 | 원단 LOT | LOT 번호 (링크, 파란색) |
|
||||||
|
| 5 | 생산 LOT | LOT 번호 (링크, 파란색) - 자동생성 규칙 있음 |
|
||||||
|
| 6 | 중간검사성적서 | PDF 아이콘 (클릭→PDF 보기/다운로드) |
|
||||||
|
| 7 | 품목명 | C(케이스), R(가이드레일) 등 약어+전체명 |
|
||||||
|
| 8 | 종류 | F(전면부), L(린텔부) 등 약어+전체명 |
|
||||||
|
| 9 | 모양&길이 | 40(4000) 형식 |
|
||||||
|
| 10 | 수량 | 숫자 |
|
||||||
|
| 11 | 작성 | 작성자 |
|
||||||
|
| 12 | 비고 | 메모 |
|
||||||
|
|
||||||
|
**액션 버튼:**
|
||||||
|
- `신규` → 등록 폼 팝업
|
||||||
|
- `업로드` → 일괄 업로드 (엑셀 등)
|
||||||
|
|
||||||
|
**LOT 번호 자동생성 규칙:**
|
||||||
|
- 형식: `{품목코드}{종류코드}{생산코드}{날짜}-{길이코드}`
|
||||||
|
- 예: `CF4A15-40` = C(케이스) + F(전면부) + 4(연도끝자리) + A(상반기) + 15(날짜) + 40(4000mm)
|
||||||
|
|
||||||
|
#### 구현 포인트
|
||||||
|
- **LOT 번호 자동생성**: 품목/종류/날짜 기반 규칙 엔진
|
||||||
|
- **중간검사성적서 PDF**: PDF 뷰어 통합 (미리보기/다운로드)
|
||||||
|
- **원자재/원단 LOT 연결**: LOT 선택 모달 (기존 수입검사 LOT 연계)
|
||||||
|
- **업로드 기능**: 엑셀 일괄 등록
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-3. 가이드레일 (Guiderail)
|
||||||
|
|
||||||
|
#### 목록 페이지
|
||||||
|
**필터 영역:**
|
||||||
|
| 필터명 | 타입 | 옵션 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 대분류 | 토글 | 전체/스크린/철재 |
|
||||||
|
| 인정/비인정 | 토글 | 전체/인정/비인정 |
|
||||||
|
| 모델 선택 | Select | (모델 선택) - 동적 |
|
||||||
|
| 키워드 검색 | 텍스트 입력 | |
|
||||||
|
|
||||||
|
**테이블 컬럼:**
|
||||||
|
| 순서 | 컬럼명 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 1 | 번호 | 순번 |
|
||||||
|
| 2 | 등록일 | yyyy-MM-dd |
|
||||||
|
| 3 | 대분류 | 스크린/철재 |
|
||||||
|
| 4 | 인정/비인정 | |
|
||||||
|
| 5 | 제품코드 | KSS02, KQTS01 등 |
|
||||||
|
| 6 | 품목검색어 | |
|
||||||
|
| 7 | 가로(너비) X 세로(폭) | 치수 (링크, 파란색) |
|
||||||
|
| 8 | 형상 | 벽면형/측면형 (색상 구분) |
|
||||||
|
| 9 | 마감 | SUS마감/EGI마감 등 |
|
||||||
|
| 10 | 소요자재량 | SUS 1.2T(406) EGI 1.55T(398) 형식 |
|
||||||
|
| 11 | 형태 | 조립도 이미지 (썸네일) |
|
||||||
|
| 12 | 작업지시서 | `보기` 버튼 |
|
||||||
|
| 13 | 작성 | 작성자 |
|
||||||
|
| 14 | 비고 | 메모 |
|
||||||
|
|
||||||
|
**특수 액션 버튼:**
|
||||||
|
- `신규` → 등록 폼 팝업
|
||||||
|
- `결합형태 이미지 등록` → 조립 이미지 관리
|
||||||
|
- `형태별 기본 전개도` → 기본 전개도 조회/관리
|
||||||
|
|
||||||
|
**모달/팝업:**
|
||||||
|
1. **작업지시서 보기** → 인쇄 가능한 작업지시서 문서 (PDF 또는 프린트 뷰)
|
||||||
|
2. **결합형태 이미지** → 이미지 업로드/관리
|
||||||
|
3. **기본 전개도** → 형태별 전개도 이미지/설정
|
||||||
|
|
||||||
|
#### 구현 포인트
|
||||||
|
- **제품코드 체계**: 모델별 자동 코드 생성
|
||||||
|
- **소요자재량 계산**: 치수 기반 자동 산출
|
||||||
|
- **작업지시서**: 인쇄용 레이아웃 (기존 품질관리 문서 패턴 활용)
|
||||||
|
- **전개도 관리**: 형태(벽면형/측면형)별 기본 템플릿
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-4. 케이스 / 셔터박스 (Shutterbox)
|
||||||
|
|
||||||
|
#### 목록 페이지
|
||||||
|
**필터 영역:**
|
||||||
|
| 필터명 | 타입 | 옵션 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 점검구 형태 | 토글 | 전체/양면 점검구/밑면 점검구/후면 점검구 |
|
||||||
|
| 키워드 검색 | 텍스트 입력 | |
|
||||||
|
|
||||||
|
**테이블 컬럼:**
|
||||||
|
| 순서 | 컬럼명 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 1 | 번호 | 순번 |
|
||||||
|
| 2 | 등록일 | yyyy-MM-dd |
|
||||||
|
| 3 | 박스(가로X세로) | 치수 (링크, 파란색) |
|
||||||
|
| 4 | 점검구 형태 | 양면/밑면/후면 점검구 (색상 구분) |
|
||||||
|
| 5 | 전면부 밑면 치수 | 숫자 |
|
||||||
|
| 6 | 레일(폭) | 숫자 |
|
||||||
|
| 7 | 소요자재량 | EGI 1.55T(2,652) 형식 |
|
||||||
|
| 8 | 품목 검색어 | |
|
||||||
|
| 9 | 형태 | 조립도 이미지 (가로세로 표기 포함) |
|
||||||
|
| 10 | 작업지시서 | `보기` 버튼 |
|
||||||
|
| 11 | 작성 | 작성자 |
|
||||||
|
| 12 | 비고 | 메모 |
|
||||||
|
|
||||||
|
**특수 액션 버튼:**
|
||||||
|
- `신규` → 등록 폼 팝업
|
||||||
|
- `결합형태 이미지 등록` → 조립 이미지 관리
|
||||||
|
- `점검구 형태별 기본 전개도` → 점검구 타입별 전개도 관리
|
||||||
|
|
||||||
|
#### 구현 포인트
|
||||||
|
- **박스 치수 기반 자동계산**: 가로×세로 입력 시 각 부품별 소요자재량 자동 산출
|
||||||
|
- **점검구 형태에 따른 전개도 차이**: 양면/밑면/후면 각각 다른 전개도 로직
|
||||||
|
- **조립도 이미지**: 치수 표기 포함된 SVG/Canvas 렌더링
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-5. 하단마감재 (Bottombar)
|
||||||
|
|
||||||
|
#### 목록 페이지
|
||||||
|
**필터 영역:**
|
||||||
|
| 필터명 | 타입 | 옵션 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 대분류 | 토글 | 전체/스크린/철재 |
|
||||||
|
| 인정/비인정 | 토글 | 전체/인정/비인정 |
|
||||||
|
| 모델 선택 | Select | (모델 선택) |
|
||||||
|
| 키워드 검색 | 텍스트 입력 | |
|
||||||
|
|
||||||
|
**테이블 컬럼:**
|
||||||
|
| 순서 | 컬럼명 | 설명 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 1 | 번호 | 순번 |
|
||||||
|
| 2 | 등록일 | yyyy-MM-dd |
|
||||||
|
| 3 | 대분류 | 스크린/철재 |
|
||||||
|
| 4 | 인정/비인정 | |
|
||||||
|
| 5 | 제품코드 | KSS01, KTE01 등 |
|
||||||
|
| 6 | 가로(폭) X 세로(높이) | 치수 (링크, 파란색) |
|
||||||
|
| 7 | 품목검색어 | |
|
||||||
|
| 8 | 마감형태 | SUS마감/EGI마감 (색상 강조) |
|
||||||
|
| 9 | 소요자재량 | 재질별 소요량 |
|
||||||
|
| 10 | 형태 | 전개도 이미지 (상세 치수 표기) |
|
||||||
|
| 11 | 작업지시서 | `보기` 버튼 |
|
||||||
|
| 12 | 작성 | 작성자 |
|
||||||
|
| 13 | 비고 | 메모 |
|
||||||
|
|
||||||
|
**액션 버튼:**
|
||||||
|
- `신규` → 등록 폼 팝업
|
||||||
|
- `이미지 등록` → 형태 이미지 관리
|
||||||
|
|
||||||
|
#### 구현 포인트
|
||||||
|
- 가이드레일과 구조가 유사 → 공통 컴포넌트 추출 가능
|
||||||
|
- 하단마감재 전용 전개도 로직
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2-6. 절곡 재고현황 (Bending Stock)
|
||||||
|
|
||||||
|
#### 대시보드 형태 페이지 (목록이 아닌 집계 화면)
|
||||||
|
|
||||||
|
**필터 영역:**
|
||||||
|
| 필터명 | 타입 | 설명 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 기간 시작 | DatePicker | 기본값: 2024.01.01 |
|
||||||
|
| 기간 종료 | DatePicker | 기본값: 오늘 |
|
||||||
|
| 품목명 | Select | |
|
||||||
|
| 종류명 | Select | |
|
||||||
|
| 모양&길이 | Select | |
|
||||||
|
| 키워드 검색 | 텍스트 입력 | |
|
||||||
|
| 그룹 선택 | 체크박스 그룹 | 전체선택/전체해제 + 가이드레일/케이스/하단마감재/기타 |
|
||||||
|
|
||||||
|
**전체 재고 요약 카드:**
|
||||||
|
| 항목 | 색상 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| 총 생산량 | 파랑 | 전체 생산 수량 합계 |
|
||||||
|
| 총 사용량 | 빨강 | 전체 사용(출고) 수량 합계 |
|
||||||
|
| 총 재고량 | 초록 | 생산량 - 사용량 |
|
||||||
|
|
||||||
|
**그룹별 재고 현황 테이블 (4개 섹션):**
|
||||||
|
1. 가이드레일 재고 현황
|
||||||
|
2. 케이스 재고 현황
|
||||||
|
3. 하단마감재 재고 현황
|
||||||
|
4. 기타 재고 현황
|
||||||
|
|
||||||
|
각 테이블 컬럼:
|
||||||
|
| 컬럼명 | 설명 |
|
||||||
|
|--------|------|
|
||||||
|
| 품목명 | 가이드레일(벽면형), 케이스 등 |
|
||||||
|
| 종류 | C형, D형, 본체 등 |
|
||||||
|
| 모양&길이 | 2438, 3000, 3500 등 |
|
||||||
|
| 생산량 | 숫자 |
|
||||||
|
| 품목코드 | RC24, RD30 등 |
|
||||||
|
| 사용량 | 숫자 |
|
||||||
|
| 재고량 | 숫자 (생산량-사용량) |
|
||||||
|
| 상태 | "재고있음" / "재고없음" / "부족" |
|
||||||
|
|
||||||
|
**액션 버튼:**
|
||||||
|
- `신규` → LOT 신규 등록
|
||||||
|
- `작업일지` → 작업일지 페이지 이동
|
||||||
|
- `업로드` → 일괄 업로드
|
||||||
|
|
||||||
|
#### 구현 포인트
|
||||||
|
- **집계 데이터**: 서버 사이드 집계 API 필요
|
||||||
|
- **그룹별 접기/펼치기**: Accordion 패턴
|
||||||
|
- **상태 색상 코딩**: 재고있음(녹색), 부족(노란색), 재고없음(빨간색)
|
||||||
|
- **체크박스 그룹 필터**: 실시간 테이블 섹션 토글
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 공통 컴포넌트 / 패턴
|
||||||
|
|
||||||
|
### 3-1. 공통으로 추출할 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 사용처 | 설명 |
|
||||||
|
|---------|--------|------|
|
||||||
|
| `WorkOrderViewer` | 가이드레일, 케이스, 하단마감재 | 작업지시서 보기/인쇄 모달 |
|
||||||
|
| `BlueprintImageManager` | 가이드레일, 케이스 | 결합형태 이미지 업로드/관리 |
|
||||||
|
| `BendingFilterBar` | 바라시, 가이드레일, 하단마감재 | 대분류/인정 토글 + 모델 선택 필터 |
|
||||||
|
| `MaterialCalculation` | 가이드레일, 케이스, 하단마감재 | 소요자재량 표시 컴포넌트 |
|
||||||
|
| `LotNumberGenerator` | LOT 관리 | LOT 번호 자동생성 로직 |
|
||||||
|
|
||||||
|
### 3-2. SAM 기존 패턴 적용
|
||||||
|
|
||||||
|
| 요소 | SAM 패턴 | 적용 방법 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 목록 페이지 | IntegratedListTemplateV2 또는 UniversalListPage | 필터+테이블+페이지네이션 |
|
||||||
|
| 등록/수정 | 모달 팝업 (버디가 팝업 사용) | Dialog 기반 폼 |
|
||||||
|
| 필터 토글 | ToggleGroup (ui/) | 전체/스크린/철재 등 |
|
||||||
|
| Select 필터 | Select (ui/) | 중분류, 품명, 모델 등 |
|
||||||
|
| 테이블 | DataTable + 컬럼 설정 | useColumnSettings 적용 |
|
||||||
|
| PDF 보기 | 기존 PDF 뷰어 또는 새 창 | 중간검사성적서 |
|
||||||
|
| 날짜 | DatePicker (기존) | 등록일, 기간 필터 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 백엔드 API 연동 예상
|
||||||
|
|
||||||
|
### 4-1. 필요 API 엔드포인트 (예상)
|
||||||
|
|
||||||
|
```
|
||||||
|
# 절곡 바라시 기초자료
|
||||||
|
GET /api/v1/bending ← 목록 조회 (필터/페이지네이션)
|
||||||
|
GET /api/v1/bending/{id} ← 상세 조회
|
||||||
|
POST /api/v1/bending ← 등록
|
||||||
|
PUT /api/v1/bending/{id} ← 수정
|
||||||
|
DELETE /api/v1/bending/{id} ← 삭제
|
||||||
|
POST /api/v1/bending/{id}/copy ← 복사
|
||||||
|
|
||||||
|
# 재고생산/LOT
|
||||||
|
GET /api/v1/bending-lot ← LOT 목록
|
||||||
|
GET /api/v1/bending-lot/{id} ← LOT 상세
|
||||||
|
POST /api/v1/bending-lot ← LOT 등록
|
||||||
|
PUT /api/v1/bending-lot/{id} ← LOT 수정
|
||||||
|
POST /api/v1/bending-lot/upload ← 일괄 업로드
|
||||||
|
GET /api/v1/bending-lot/{id}/inspection-report ← 중간검사성적서 PDF
|
||||||
|
|
||||||
|
# 가이드레일
|
||||||
|
GET /api/v1/guiderail ← 목록
|
||||||
|
GET /api/v1/guiderail/{id} ← 상세
|
||||||
|
POST /api/v1/guiderail ← 등록
|
||||||
|
PUT /api/v1/guiderail/{id} ← 수정
|
||||||
|
GET /api/v1/guiderail/{id}/work-order ← 작업지시서
|
||||||
|
GET /api/v1/guiderail/blueprints ← 기본 전개도
|
||||||
|
POST /api/v1/guiderail/blueprint-image ← 결합형태 이미지 등록
|
||||||
|
|
||||||
|
# 케이스 (셔터박스)
|
||||||
|
GET /api/v1/shutterbox ← 목록
|
||||||
|
GET /api/v1/shutterbox/{id} ← 상세
|
||||||
|
POST /api/v1/shutterbox ← 등록
|
||||||
|
PUT /api/v1/shutterbox/{id} ← 수정
|
||||||
|
GET /api/v1/shutterbox/{id}/work-order ← 작업지시서
|
||||||
|
GET /api/v1/shutterbox/blueprints ← 기본 전개도
|
||||||
|
POST /api/v1/shutterbox/blueprint-image ← 결합형태 이미지 등록
|
||||||
|
|
||||||
|
# 하단마감재
|
||||||
|
GET /api/v1/bottombar ← 목록
|
||||||
|
GET /api/v1/bottombar/{id} ← 상세
|
||||||
|
POST /api/v1/bottombar ← 등록
|
||||||
|
PUT /api/v1/bottombar/{id} ← 수정
|
||||||
|
GET /api/v1/bottombar/{id}/work-order ← 작업지시서
|
||||||
|
POST /api/v1/bottombar/image ← 이미지 등록
|
||||||
|
|
||||||
|
# 절곡 재고현황
|
||||||
|
GET /api/v1/bending-stock/summary ← 전체 재고 요약
|
||||||
|
GET /api/v1/bending-stock/by-group ← 그룹별 재고 현황
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 구현 우선순위 및 단계
|
||||||
|
|
||||||
|
### Phase 1: 기초 인프라 + 단순 목록 (1주)
|
||||||
|
- [ ] 라우트 구조 생성 (6개 page.tsx)
|
||||||
|
- [ ] types.ts 정의 (전체 타입)
|
||||||
|
- [ ] actions.ts 기본 구조 (API 연동 준비)
|
||||||
|
- [ ] 하단마감재 목록/폼 (가장 단순, 11건)
|
||||||
|
- [ ] 가이드레일 목록/폼
|
||||||
|
|
||||||
|
### Phase 2: 핵심 기능 (1주)
|
||||||
|
- [ ] 케이스(셔터박스) 목록/폼
|
||||||
|
- [ ] 작업지시서 보기 공통 컴포넌트
|
||||||
|
- [ ] 결합형태 이미지 관리 공통 컴포넌트
|
||||||
|
- [ ] 기본 전개도 뷰어
|
||||||
|
|
||||||
|
### Phase 3: 바라시 기초자료 (3~5일) ← 기존 그리기 도구 재활용으로 단축
|
||||||
|
- [ ] 절곡 바라시 기초자료 목록
|
||||||
|
- [ ] 절곡 바라시 등록 폼 (동적 행 + 자동계산)
|
||||||
|
- [ ] 기존 DrawingCanvas 연동 (items/DrawingCanvas.tsx 그대로 import)
|
||||||
|
- [ ] 이미지 붙여넣기 기능 (Clipboard API)
|
||||||
|
|
||||||
|
### Phase 4: LOT + 재고 (1주)
|
||||||
|
- [ ] 재고생산/작업일지/중간검사성적서 목록/폼
|
||||||
|
- [ ] LOT 번호 자동생성 로직
|
||||||
|
- [ ] 중간검사성적서 PDF 연동
|
||||||
|
- [ ] 절곡 재고현황 대시보드
|
||||||
|
- [ ] 재고 요약 카드 + 그룹별 테이블
|
||||||
|
|
||||||
|
### Phase 5: 고도화 (선택)
|
||||||
|
- [ ] 절곡 모델설정 페이지
|
||||||
|
- [ ] 절곡 BOM 관리 페이지
|
||||||
|
- [ ] 엑셀 업로드/다운로드
|
||||||
|
- [ ] 인쇄 최적화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 리스크 및 주의사항
|
||||||
|
|
||||||
|
| 리스크 | 영향도 | 대응 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| ~~그리기 도구 구현 복잡도~~ | ~~높음~~ | ✅ **해결**: 기존 DrawingCanvas 재활용 (품목관리에서 사용 중) |
|
||||||
|
| API 엔드포인트 미확정 | 중간 | Mock 데이터로 UI 선구현 후 API 연동 |
|
||||||
|
| LOT 번호 생성 규칙 확인 필요 | 중간 | 백엔드 팀과 규칙 확정 필요 |
|
||||||
|
| 전개도/조립도 이미지 관리 | 낮음 | R2 스토리지 + 기존 BendingDiagramSection 패턴 재활용 |
|
||||||
|
| 소요자재량 계산 로직 | 중간 | 백엔드 API에서 계산 후 반환 vs 프론트 계산 확정 필요 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 확인 필요 사항 (백엔드 팀)
|
||||||
|
|
||||||
|
1. **API 엔드포인트 명명 규칙** 확정 (위 예상과 맞는지)
|
||||||
|
2. **LOT 번호 자동생성 규칙** 정확한 로직 (프론트 생성 vs 백엔드 생성)
|
||||||
|
3. **소요자재량 계산** 위치 (프론트 vs 백엔드)
|
||||||
|
4. **중간검사성적서 PDF** 생성/저장 방식
|
||||||
|
5. **작업지시서** 데이터 구조 및 생성 방식
|
||||||
|
6. **절곡 모델설정 / BOM** 별도 페이지 여부 및 구조
|
||||||
|
7. **이미지 저장** 경로 (R2 스토리지 활용?)
|
||||||
124
claudedocs/quality/[IMPL-2026-03-07] quality-api-migration.md
Normal file
124
claudedocs/quality/[IMPL-2026-03-07] quality-api-migration.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# 품질관리 Mock→API 전환 및 검사 모달/문서 개선
|
||||||
|
|
||||||
|
> **작업일**: 2026-03-05 ~ 03-07
|
||||||
|
> **상태**: ✅ 완료
|
||||||
|
> **커밋**: 50e4c72c, 563b240f, 899493a7, fe930b58, e7263fee, 4ea03922, 295585d8, c150d807, e75d8f9b
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
품질관리(InspectionManagement) 전체 모듈을 Mock 데이터에서 실제 API로 전환하고,
|
||||||
|
검사 모달/문서 렌더링/수주선택 기능을 대폭 개선한 작업.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. API 전환
|
||||||
|
|
||||||
|
- [x] `USE_MOCK_FALLBACK = false` 설정, Mock 데이터 제거
|
||||||
|
- [x] 엔드포인트 연동
|
||||||
|
- `GET /api/v1/quality/documents` — 검사 목록
|
||||||
|
- `GET /api/v1/quality/documents/{id}` — 검사 상세
|
||||||
|
- `POST /api/v1/quality/documents` — 검사 등록
|
||||||
|
- `PUT /api/v1/quality/documents/{id}` — 검사 수정
|
||||||
|
- `GET /api/v1/quality/performance-reports` — 실적신고 목록
|
||||||
|
- [x] snake_case → camelCase 변환 함수 구현
|
||||||
|
- [x] InspectionFormData 필드 추가: `clientId`, `inspectorId`, `receptionDate`
|
||||||
|
- [x] 실적신고 API 응답 snake_case → camelCase 변환
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/quality/InspectionManagement/actions.ts`
|
||||||
|
- `src/components/quality/PerformanceReportManagement/actions.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 검사 모달 개선 (ProductInspectionInputModal)
|
||||||
|
|
||||||
|
- [x] 기본값 null(미선택) 상태로 변경
|
||||||
|
- [x] 일괄 합격/초기화 토글 버튼
|
||||||
|
- [x] 시공 치수 필드 (너비/높이) — ConstructionInfo 인터페이스
|
||||||
|
- [x] 변경사유 입력 필드
|
||||||
|
- [x] 사진 첨부 (최대 2장, base64 인코딩)
|
||||||
|
- [x] 이전/다음 개소 네비게이션 + 자동저장
|
||||||
|
- [x] 레거시 검사 데이터 통합 (합격/불합격/진행중/미완)
|
||||||
|
- [x] 사진 없는 항목 → "진행중" 상태 표시
|
||||||
|
- [x] Eye 아이콘 → "보기" 텍스트 배지 변경
|
||||||
|
- [x] 배지 사이즈 통일
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/quality/InspectionManagement/ProductInspectionInputModal.tsx` (+428/-210)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 수주선택 모달 (OrderSelectModal)
|
||||||
|
|
||||||
|
- [x] 발주처(clientName) 컬럼 추가
|
||||||
|
- [x] 모델명 컬럼 추가
|
||||||
|
- [x] 동일 발주처 + 동일 모델 필터링 제약
|
||||||
|
- [x] 모달 너비 확장: `sm:max-w-2xl` → `sm:max-w-3xl`
|
||||||
|
- [x] 수주 선택 시 개소 자동 펼침
|
||||||
|
- [x] 필터 안내 텍스트 추가
|
||||||
|
|
||||||
|
### SearchableSelectionModal 공통 컴포넌트 확장
|
||||||
|
- [x] `isItemDisabled` 콜백 prop 추가
|
||||||
|
- [x] 비활성 항목 스타일링 (opacity 감소, cursor 변경)
|
||||||
|
- [x] 전체선택 시 비활성 항목 제외
|
||||||
|
- [x] 이미 선택된 항목은 비활성이라도 해제 가능
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/quality/InspectionManagement/OrderSelectModal.tsx`
|
||||||
|
- `src/components/organisms/SearchableSelectionModal/SearchableSelectionModal.tsx`
|
||||||
|
- `src/components/organisms/SearchableSelectionModal/types.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 제품검사 성적서 (FqcDocumentContent) — 신규
|
||||||
|
|
||||||
|
8컬럼 동적 렌더링 테이블 구현.
|
||||||
|
|
||||||
|
| 컬럼 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| No | 순번 |
|
||||||
|
| 검사항목 | 카테고리 기반 rowSpan 병합 |
|
||||||
|
| 세부항목 | 개별 항목명 |
|
||||||
|
| 검사기준 | 스펙/기준값 |
|
||||||
|
| 검사방법 | method + frequency 복합 rowSpan 병합 |
|
||||||
|
| 검사주기 | (검사방법과 함께 병합) |
|
||||||
|
| 측정값 | measurement_type에 따라: checkbox→양호/불량, numeric→숫자입력, none→비활성 |
|
||||||
|
| 판정 | 적합/부적합/null |
|
||||||
|
|
||||||
|
- [x] `buildFieldRowSpan` — 단일 필드 병합 (카테고리)
|
||||||
|
- [x] `buildCompositeRowSpan` — 복합 필드 병합 (method+frequency)
|
||||||
|
- [x] FQC 모드 우선 + legacy fallback 패턴
|
||||||
|
- [x] `useImperativeHandle`로 `getInspectionData()` 외부 접근
|
||||||
|
- [x] Lazy Snapshot 준비 (`contentWrapperRef`)
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/quality/InspectionManagement/documents/FqcDocumentContent.tsx` (신규, +483)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 제품검사 요청서 (FqcRequestDocumentContent) — 신규
|
||||||
|
|
||||||
|
양식 기반(template_id: 66) 동적 렌더링 구현.
|
||||||
|
|
||||||
|
- [x] 결재라인 섹션
|
||||||
|
- [x] 기본정보 섹션 (7개 필드, 2컬럼 배치)
|
||||||
|
- [x] 입력 섹션 4개: 현장, 자재유통사, 시공자, 감리
|
||||||
|
- [x] 사전통보 테이블 (group_name 기반 3단계 헤더)
|
||||||
|
- [x] 오픈사이즈 발주 / 시공 치수 그룹 병합
|
||||||
|
- [x] EAV 데이터 구조: `section_id`, `column_id`, `row_index`, `field_key`, `field_value`
|
||||||
|
- [x] EAV 문서 없을 때 legacy fallback 적용
|
||||||
|
|
||||||
|
### 주요 파일
|
||||||
|
- `src/components/quality/InspectionManagement/documents/FqcRequestDocumentContent.tsx` (신규, +461)
|
||||||
|
- `src/components/quality/InspectionManagement/documents/InspectionRequestModal.tsx`
|
||||||
|
- `src/components/quality/InspectionManagement/fqcActions.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 수주 연결 동기화
|
||||||
|
|
||||||
|
- [x] `order_ids` 배열 매핑 (다중 수주 지원)
|
||||||
|
- [x] 개소별 `inspectionData` 서버 저장
|
||||||
|
- [x] FQC 문서에서 수주 연결 정보 동기화
|
||||||
@@ -15,6 +15,7 @@ const eslintConfig = [
|
|||||||
"node_modules/**",
|
"node_modules/**",
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
"src/components/_unused/**", // Archived unused components
|
"src/components/_unused/**", // Archived unused components
|
||||||
|
"src/components/settings/AccountManagement/_legacy/**", // Legacy files
|
||||||
"src/hooks/useCurrentTime.ts", // Demo hook
|
"src/hooks/useCurrentTime.ts", // Demo hook
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -76,9 +77,38 @@ const eslintConfig = [
|
|||||||
HTMLTableCaptionElement: "readonly",
|
HTMLTableCaptionElement: "readonly",
|
||||||
HTMLTextAreaElement: "readonly",
|
HTMLTextAreaElement: "readonly",
|
||||||
HTMLCanvasElement: "readonly",
|
HTMLCanvasElement: "readonly",
|
||||||
|
HTMLDivElement: "readonly",
|
||||||
|
HTMLElement: "readonly",
|
||||||
|
HTMLImageElement: "readonly",
|
||||||
ImageData: "readonly",
|
ImageData: "readonly",
|
||||||
Image: "readonly",
|
Image: "readonly",
|
||||||
prompt: "readonly",
|
prompt: "readonly",
|
||||||
|
Audio: "readonly",
|
||||||
|
Blob: "readonly",
|
||||||
|
CSSStyleDeclaration: "readonly",
|
||||||
|
CustomEvent: "readonly",
|
||||||
|
Element: "readonly",
|
||||||
|
ErrorEvent: "readonly",
|
||||||
|
Event: "readonly",
|
||||||
|
FileList: "readonly",
|
||||||
|
FileReader: "readonly",
|
||||||
|
Headers: "readonly",
|
||||||
|
IntersectionObserver: "readonly",
|
||||||
|
KeyboardEvent: "readonly",
|
||||||
|
MouseEvent: "readonly",
|
||||||
|
Node: "readonly",
|
||||||
|
NodeJS: "readonly",
|
||||||
|
PromiseRejectionEvent: "readonly",
|
||||||
|
RequestCache: "readonly",
|
||||||
|
ResizeObserver: "readonly",
|
||||||
|
Storage: "readonly",
|
||||||
|
cancelAnimationFrame: "readonly",
|
||||||
|
crypto: "readonly",
|
||||||
|
getComputedStyle: "readonly",
|
||||||
|
google: "readonly",
|
||||||
|
navigator: "readonly",
|
||||||
|
requestAnimationFrame: "readonly",
|
||||||
|
sessionStorage: "readonly",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -95,7 +125,9 @@ const eslintConfig = [
|
|||||||
"@typescript-eslint/no-explicit-any": "warn",
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
"@typescript-eslint/no-unused-vars": ["error", {
|
"@typescript-eslint/no-unused-vars": ["error", {
|
||||||
"argsIgnorePattern": "^_",
|
"argsIgnorePattern": "^_",
|
||||||
"varsIgnorePattern": "^_"
|
"varsIgnorePattern": "^_",
|
||||||
|
"caughtErrorsIgnorePattern": "^_",
|
||||||
|
"destructuredArrayIgnorePattern": "^_"
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
|
reactStrictMode: false, // 🧪 TEST: Strict Mode 비활성화로 중복 요청 테스트
|
||||||
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
turbopack: {}, // ✅ CRITICAL: Next.js 15 + next-intl compatibility
|
||||||
|
allowedDevOrigins: ['dev.sam.kr', '192.168.0.*'], // 로컬 도메인 + 네트워크 기기 접속 허용
|
||||||
serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외
|
serverExternalPackages: ['puppeteer'], // PDF 생성용 - Webpack 번들 제외
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
@@ -28,6 +29,9 @@ const nextConfig: NextConfig = {
|
|||||||
},
|
},
|
||||||
// Capacitor 패키지는 모바일 앱 전용 - 웹 빌드에서 제외
|
// Capacitor 패키지는 모바일 앱 전용 - 웹 빌드에서 제외
|
||||||
webpack: (config, { isServer }) => {
|
webpack: (config, { isServer }) => {
|
||||||
|
// macOS 26 호환성: webpack 캐시 비활성화 (rename ENOENT 방지)
|
||||||
|
config.cache = false;
|
||||||
|
|
||||||
if (!isServer) {
|
if (!isServer) {
|
||||||
config.resolve.fallback = {
|
config.resolve.fallback = {
|
||||||
...config.resolve.fallback,
|
...config.resolve.fallback,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbo",
|
"dev": "node scripts/validate-next-cache.mjs && NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
|
"build:restart": "lsof -ti:3000 | xargs kill 2>/dev/null; next build && next start &",
|
||||||
"start": "next start -H 0.0.0.0",
|
"start": "next start -H 0.0.0.0",
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"next": "^15.5.9",
|
"next": "^15.5.9",
|
||||||
"next-intl": "^4.4.0",
|
"next-intl": "^4.4.0",
|
||||||
"puppeteer": "^24.37.2",
|
"puppeteer": "^24.37.2",
|
||||||
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "^9.11.1",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
|||||||
90
sam-docs/frontend/v1/00-overview.md
Normal file
90
sam-docs/frontend/v1/00-overview.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# SAM ERP 프론트엔드 개발 가이드
|
||||||
|
|
||||||
|
> **대상**: SAM ERP 프론트엔드 신규/기존 개발자
|
||||||
|
> **최종 업데이트**: 2026-03-13
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 목차
|
||||||
|
|
||||||
|
| 문서 | 내용 |
|
||||||
|
|------|------|
|
||||||
|
| [00-overview.md](./00-overview.md) | 프로젝트 개요 및 기술 스택 (이 문서) |
|
||||||
|
| [01-project-structure.md](./01-project-structure.md) | 디렉토리 구조 및 파일 배치 규칙 |
|
||||||
|
| [02-routing-and-pages.md](./02-routing-and-pages.md) | 라우팅, 페이지 모드, 레이아웃 |
|
||||||
|
| [03-authentication.md](./03-authentication.md) | 인증 흐름, HttpOnly 쿠키, API 프록시 |
|
||||||
|
| [04-server-actions.md](./04-server-actions.md) | Server Action 패턴, API 통신 유틸리티 |
|
||||||
|
| [05-common-components.md](./05-common-components.md) | 공통 컴포넌트 (organisms, molecules, templates) |
|
||||||
|
| [06-ui-components.md](./06-ui-components.md) | UI 컴포넌트 카탈로그 |
|
||||||
|
| [07-hooks.md](./07-hooks.md) | 공통 Hooks |
|
||||||
|
| [08-utilities.md](./08-utilities.md) | 유틸리티 함수 (포맷터, URL 빌더, 인쇄 등) |
|
||||||
|
| [09-coding-conventions.md](./09-coding-conventions.md) | 코딩 컨벤션 및 필수 규칙 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 프로젝트 개요
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
프로젝트: SAM ERP (통합 자원관리 시스템)
|
||||||
|
프론트엔드: Next.js 15 (App Router, TypeScript)
|
||||||
|
백엔드 API: PHP Laravel (sam-api)
|
||||||
|
특성: 인증 필수 폐쇄형 ERP (SEO 불필요, 크롤링 차단)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 저장소 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
sam_project/
|
||||||
|
├── sam-next/sma-next-project/sam-react-prod/ # Next.js 프론트엔드 (현재)
|
||||||
|
├── sam-api/sam-api/ # PHP Laravel 백엔드 API
|
||||||
|
├── sam-design/sam-design/ # React 디자인 시스템
|
||||||
|
└── sam-hotfix/sam-hotfix/ # E2E 테스트/핫픽스 관리
|
||||||
|
```
|
||||||
|
|
||||||
|
### 기술 스택
|
||||||
|
|
||||||
|
| 카테고리 | 기술 |
|
||||||
|
|----------|------|
|
||||||
|
| **프레임워크** | Next.js 15 (App Router, Turbopack) |
|
||||||
|
| **언어** | TypeScript (strict) |
|
||||||
|
| **스타일링** | Tailwind CSS |
|
||||||
|
| **UI 라이브러리** | Radix UI (shadcn/ui 기반) |
|
||||||
|
| **상태 관리** | Zustand |
|
||||||
|
| **폼 관리** | react-hook-form + Zod (신규 폼) |
|
||||||
|
| **차트** | Recharts |
|
||||||
|
| **국제화** | next-intl (ko, en, ja) |
|
||||||
|
| **토스트** | Sonner |
|
||||||
|
| **아이콘** | Lucide React |
|
||||||
|
| **날짜** | date-fns (한국어 locale) |
|
||||||
|
| **인증** | HttpOnly Cookie + API Proxy |
|
||||||
|
| **모바일** | Capacitor (하이브리드 앱) |
|
||||||
|
|
||||||
|
### 핵심 원칙
|
||||||
|
|
||||||
|
1. **모든 페이지는 Client Component** (`'use client'`) - 폐쇄형 ERP이므로 SEO 불필요, 서버 컴포넌트에서 쿠키 갱신 불가
|
||||||
|
2. **HttpOnly 쿠키 인증** - JavaScript에서 토큰 접근 불가, API Proxy 필수
|
||||||
|
3. **mode 쿼리파라미터** - `/new`, `/edit` 별도 경로 대신 `?mode=new`, `?mode=edit` 사용
|
||||||
|
4. **buildApiUrl 필수** - URL 직접 조립 금지
|
||||||
|
5. **컴포넌트 재사용** - 새 컴포넌트 전 기존 컴포넌트 검색 필수
|
||||||
|
|
||||||
|
### 개발 환경
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 로컬 개발 서버
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 타입 체크
|
||||||
|
npx tsc --noEmit
|
||||||
|
|
||||||
|
# 빌드 (로컬 확인용)
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 브랜치 전략
|
||||||
|
|
||||||
|
| 브랜치 | 역할 |
|
||||||
|
|--------|------|
|
||||||
|
| `develop` | 평소 작업 브랜치 (자유롭게 커밋) |
|
||||||
|
| `stage` | QA/테스트 환경 |
|
||||||
|
| `main` | 배포용 (기능별 squash merge) |
|
||||||
|
| `feature/*` | 큰 기능/실험적 작업 |
|
||||||
112
sam-docs/frontend/v1/01-project-structure.md
Normal file
112
sam-docs/frontend/v1/01-project-structure.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 프로젝트 구조 및 파일 배치 규칙
|
||||||
|
|
||||||
|
## 디렉토리 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app/ # Next.js App Router
|
||||||
|
│ ├── [locale]/ # i18n (ko, en, ja)
|
||||||
|
│ │ ├── (auth)/ # 인증 페이지 (로그인 등)
|
||||||
|
│ │ │ └── login/
|
||||||
|
│ │ ├── (protected)/ # 보호된 라우트 (인증 필수)
|
||||||
|
│ │ │ ├── accounting/ # 회계
|
||||||
|
│ │ │ ├── approval/ # 전자결재
|
||||||
|
│ │ │ ├── board/ # 게시판
|
||||||
|
│ │ │ ├── construction/ # 시공
|
||||||
|
│ │ │ ├── customer-center/ # 고객센터
|
||||||
|
│ │ │ ├── dashboard/ # 대시보드
|
||||||
|
│ │ │ ├── hr/ # 인사
|
||||||
|
│ │ │ ├── master-data/ # 기준정보
|
||||||
|
│ │ │ ├── material/ # 자재
|
||||||
|
│ │ │ ├── outbound/ # 출고
|
||||||
|
│ │ │ ├── production/ # 생산
|
||||||
|
│ │ │ ├── quality/ # 품질
|
||||||
|
│ │ │ ├── reports/ # 리포트
|
||||||
|
│ │ │ ├── sales/ # 영업
|
||||||
|
│ │ │ ├── settings/ # 설정
|
||||||
|
│ │ │ ├── [...slug]/ # catch-all (미구현 메뉴)
|
||||||
|
│ │ │ └── layout.tsx # 보호 레이아웃
|
||||||
|
│ │ ├── layout.tsx # 루트 레이아웃 (i18n)
|
||||||
|
│ │ └── page.tsx # / → /dashboard 리다이렉트
|
||||||
|
│ └── api/ # API Routes
|
||||||
|
│ ├── proxy/[...path]/ # HttpOnly 쿠키 프록시
|
||||||
|
│ ├── auth/ # 인증 엔드포인트
|
||||||
|
│ └── pdf/generate/ # PDF 생성
|
||||||
|
├── components/
|
||||||
|
│ ├── ui/ # 기본 UI 컴포넌트 (shadcn/ui 기반)
|
||||||
|
│ ├── molecules/ # 조합 컴포넌트 (FormField, StatusBadge 등)
|
||||||
|
│ ├── organisms/ # 페이지 구성 블록 (PageHeader, DataTable 등)
|
||||||
|
│ ├── templates/ # 페이지 템플릿 (IntegratedListTemplateV2 등)
|
||||||
|
│ ├── layout/ # 레이아웃 컴포넌트 (Sidebar, Header 등)
|
||||||
|
│ └── {domain}/ # 도메인별 컴포넌트
|
||||||
|
│ ├── accounting/
|
||||||
|
│ ├── hr/
|
||||||
|
│ ├── production/
|
||||||
|
│ ├── quality/
|
||||||
|
│ └── ...
|
||||||
|
├── hooks/ # 공통 Hooks
|
||||||
|
├── layouts/ # AuthenticatedLayout
|
||||||
|
├── lib/ # 유틸리티
|
||||||
|
│ ├── api/ # API 통신 유틸리티
|
||||||
|
│ ├── auth/ # 인증 유틸리티
|
||||||
|
│ ├── formatters.ts # 포맷팅 함수
|
||||||
|
│ ├── print-utils.ts # 인쇄 유틸리티
|
||||||
|
│ └── utils.ts # 기본 유틸리티 (cn 등)
|
||||||
|
├── stores/ # Zustand 스토어
|
||||||
|
├── i18n/ # 국제화 설정
|
||||||
|
├── types/ # 공통 타입 정의
|
||||||
|
└── styles/ # 글로벌 스타일
|
||||||
|
```
|
||||||
|
|
||||||
|
## 컴포넌트 계층
|
||||||
|
|
||||||
|
```
|
||||||
|
ui/ → 원자 컴포넌트 (Button, Input, Select ...)
|
||||||
|
molecules/ → 조합 컴포넌트 (FormField = Label + Input + Error)
|
||||||
|
organisms/ → 페이지 빌딩 블록 (PageHeader, DataTable, SearchFilter ...)
|
||||||
|
templates/ → 페이지 전체 템플릿 (IntegratedListTemplateV2)
|
||||||
|
{domain}/ → 도메인 전용 컴포넌트 (AccountingForm, QualityReport ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 파일 배치 규칙
|
||||||
|
|
||||||
|
### 도메인 컴포넌트
|
||||||
|
```
|
||||||
|
src/components/{domain}/
|
||||||
|
├── {Feature}List.tsx # 목록 컴포넌트
|
||||||
|
├── {Feature}Detail.tsx # 상세/수정/등록 컴포넌트
|
||||||
|
├── {Feature}Modal.tsx # 모달 컴포넌트
|
||||||
|
└── actions.ts # Server Actions
|
||||||
|
```
|
||||||
|
|
||||||
|
### 페이지 파일
|
||||||
|
```
|
||||||
|
src/app/[locale]/(protected)/{domain}/{feature}/
|
||||||
|
├── page.tsx # 목록 + mode=new 분기
|
||||||
|
├── [id]/
|
||||||
|
│ └── page.tsx # 상세 + mode=edit 분기
|
||||||
|
└── actions.ts # (또는 components/{domain}/actions.ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Server Actions 위치
|
||||||
|
- **우선**: `src/components/{domain}/actions.ts` (도메인별)
|
||||||
|
- **대안**: `src/app/[locale]/(protected)/{domain}/{feature}/actions.ts` (페이지별)
|
||||||
|
|
||||||
|
## 파일 네이밍
|
||||||
|
|
||||||
|
| 유형 | 네이밍 | 예시 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 컴포넌트 | PascalCase | `VendorDetail.tsx` |
|
||||||
|
| 페이지 | `page.tsx` (Next.js 규칙) | `page.tsx` |
|
||||||
|
| Server Action | `actions.ts` | `actions.ts` |
|
||||||
|
| 유틸리티 | kebab-case | `query-params.ts` |
|
||||||
|
| Hook | camelCase + `use` 접두사 | `useColumnSettings.ts` |
|
||||||
|
| 타입 | `types.ts` 또는 인라인 | `types.ts` |
|
||||||
|
| 스키마 | `schema.ts` | `schema.ts` |
|
||||||
|
|
||||||
|
## 신규 파일 생성 전 체크리스트
|
||||||
|
|
||||||
|
- [ ] 유사 컴포넌트가 이미 있는지 `organisms/`, `molecules/` 확인
|
||||||
|
- [ ] 같은 도메인에 재사용 가능한 컴포넌트 확인
|
||||||
|
- [ ] dev/component-registry 페이지에서 검색
|
||||||
|
- [ ] 공통 UI 컴포넌트(`ui/`)로 해결 가능한지 확인
|
||||||
159
sam-docs/frontend/v1/02-routing-and-pages.md
Normal file
159
sam-docs/frontend/v1/02-routing-and-pages.md
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
# 라우팅 및 페이지 패턴
|
||||||
|
|
||||||
|
## 라우팅 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
/[locale]/(auth)/login # 로그인
|
||||||
|
/[locale]/(protected)/dashboard # 대시보드
|
||||||
|
/[locale]/(protected)/{domain}/{feature} # 목록
|
||||||
|
/[locale]/(protected)/{domain}/{feature}?mode=new # 등록
|
||||||
|
/[locale]/(protected)/{domain}/{feature}/[id] # 상세(view)
|
||||||
|
/[locale]/(protected)/{domain}/{feature}/[id]?mode=edit # 수정
|
||||||
|
```
|
||||||
|
|
||||||
|
## 레이아웃 계층
|
||||||
|
|
||||||
|
```
|
||||||
|
Root Layout ([locale]/layout.tsx) - Server Component
|
||||||
|
├── i18n 설정 (NextIntlClientProvider)
|
||||||
|
├── 폰트 로드 (PretendardVariable)
|
||||||
|
├── Toaster (sonner)
|
||||||
|
└── Protected Layout ((protected)/layout.tsx) - Client Component
|
||||||
|
├── useAuthGuard() - 인증 보호
|
||||||
|
├── RootProvider - 전역 상태
|
||||||
|
├── ApiErrorProvider - 401 에러 처리
|
||||||
|
├── FCMProvider - 푸시 알림
|
||||||
|
├── PermissionGate - 권한 제어
|
||||||
|
└── AuthenticatedLayout
|
||||||
|
├── Sidebar - 메뉴
|
||||||
|
├── Header - 회사선택, 검색, 알림
|
||||||
|
├── HeaderFavoritesBar - 즐겨찾기
|
||||||
|
└── {children} - 페이지 컨텐츠
|
||||||
|
```
|
||||||
|
|
||||||
|
## 페이지 모드 패턴 (mode 쿼리파라미터)
|
||||||
|
|
||||||
|
### 규칙
|
||||||
|
- **별도 `/new`, `/edit` 경로 금지** → `?mode=new`, `?mode=edit` 사용
|
||||||
|
- 목록과 등록을 **같은 page.tsx에서 분기**
|
||||||
|
|
||||||
|
### 목록 + 등록 (page.tsx)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function ItemsPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
|
// mode=new → 등록 폼
|
||||||
|
if (mode === 'new') {
|
||||||
|
return <ItemDetail mode="new" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 → 목록
|
||||||
|
return <ItemList />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 상세 + 수정 ([id]/page.tsx)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
import { useParams, useSearchParams } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function ItemDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||||
|
|
||||||
|
return <ItemDetail id={id} mode={mode} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 네비게이션
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 목록 → 등록
|
||||||
|
router.push('/master-data/items?mode=new');
|
||||||
|
|
||||||
|
// 목록 → 상세
|
||||||
|
router.push(`/master-data/items/${id}`);
|
||||||
|
|
||||||
|
// 상세 → 수정
|
||||||
|
router.push(`/master-data/items/${id}?mode=edit`);
|
||||||
|
|
||||||
|
// 수정 → 상세 (저장 후)
|
||||||
|
router.push(`/master-data/items/${id}`);
|
||||||
|
|
||||||
|
// → 목록으로
|
||||||
|
router.push('/master-data/items');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 페이지 레이아웃 표준
|
||||||
|
|
||||||
|
### PageLayout 패딩 규칙
|
||||||
|
- `AuthenticatedLayout`의 `<main>`에는 패딩 없음
|
||||||
|
- `PageLayout` 컴포넌트가 `p-3 md:p-6` 패딩 담당
|
||||||
|
- **page.tsx에서 패딩 wrapper 추가 금지** (이중 패딩 방지)
|
||||||
|
|
||||||
|
### 등록/수정/상세 페이지 헤더
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-xl font-bold">페이지 제목</h1>
|
||||||
|
<Button variant="link" className="text-muted-foreground"
|
||||||
|
onClick={() => router.push(listPath)}>
|
||||||
|
← 목록으로
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 하단 Sticky 액션 바 (필수)
|
||||||
|
|
||||||
|
폼 페이지 하단에 sticky bar로 버튼 배치:
|
||||||
|
|
||||||
|
| 모드 | 좌측 | 우측 |
|
||||||
|
|------|------|------|
|
||||||
|
| 등록 (new) | `X 취소` | `💾 저장` |
|
||||||
|
| 상세 (view) | `X 취소` (목록으로) | `✏️ 수정` |
|
||||||
|
| 수정 (edit) | `X 취소` | `💾 저장` |
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="sticky bottom-0 bg-white border-t shadow-sm">
|
||||||
|
<div className="px-3 py-3 md:px-6 md:py-4 flex items-center justify-between">
|
||||||
|
<Button variant="outline" onClick={() => router.push(listPath)}>
|
||||||
|
<X className="h-4 w-4 mr-1" />
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isSubmitting}>
|
||||||
|
{isSubmitting
|
||||||
|
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
|
: <Save className="h-4 w-4 mr-1" />}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 테이블 표준
|
||||||
|
|
||||||
|
### 필수 컬럼 구조
|
||||||
|
**체크박스** → **번호(1부터)** → **데이터 컬럼** → **작업 컬럼**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 번호 계산 (페이지네이션 고려)
|
||||||
|
const globalIndex = (currentPage - 1) * pageSize + index + 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 작업 버튼
|
||||||
|
- 체크박스 선택 시에만 표시
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
```
|
||||||
|
지원 언어: ko (기본), en, ja
|
||||||
|
경로: /ko/..., /en/..., /ja/...
|
||||||
|
```
|
||||||
137
sam-docs/frontend/v1/03-authentication.md
Normal file
137
sam-docs/frontend/v1/03-authentication.md
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
# 인증 및 API 통신
|
||||||
|
|
||||||
|
## 인증 아키텍처
|
||||||
|
|
||||||
|
SAM ERP는 **HttpOnly Cookie 기반 인증**을 사용합니다. JavaScript에서 토큰을 직접 접근할 수 없으므로, 모든 인증 API 호출은 **Next.js API Proxy**를 통해 처리됩니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
클라이언트 (브라우저)
|
||||||
|
│
|
||||||
|
├── Server Action 호출 (useEffect에서)
|
||||||
|
│ └── serverFetch() → 서버에서 쿠키 읽기 → 백엔드 API 호출
|
||||||
|
│
|
||||||
|
└── 프록시 API 호출 (fetch('/api/proxy/...'))
|
||||||
|
└── API Proxy Route → 서버에서 쿠키 읽기 → 백엔드 API 호출
|
||||||
|
```
|
||||||
|
|
||||||
|
## 쿠키 구조
|
||||||
|
|
||||||
|
| 쿠키명 | HttpOnly | 용도 | Max-Age |
|
||||||
|
|--------|:--------:|------|---------|
|
||||||
|
| `access_token` | O | API 인증 토큰 | 2시간 |
|
||||||
|
| `refresh_token` | O | 토큰 갱신용 | 7일 |
|
||||||
|
| `is_authenticated` | X | 클라이언트 인증 상태 확인 | 2시간 |
|
||||||
|
|
||||||
|
- `access_token`, `refresh_token`: HttpOnly → JavaScript 접근 불가 (XSS 방지)
|
||||||
|
- `is_authenticated`: non-HttpOnly → 클라이언트에서 인증 상태 확인 가능 (FCM 등)
|
||||||
|
- 프로덕션: `Secure` 플래그 활성화 (HTTPS만)
|
||||||
|
- `SameSite=Lax`: CSRF 방지
|
||||||
|
|
||||||
|
## 인증 흐름
|
||||||
|
|
||||||
|
### 로그인
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 사용자 → /api/auth/login (POST)
|
||||||
|
2. 백엔드 → access_token + refresh_token 반환
|
||||||
|
3. API Route → Set-Cookie (HttpOnly) 설정
|
||||||
|
4. 클라이언트 → /(protected)/dashboard 리다이렉트
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 요청 (Server Action)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 클라이언트 → Server Action 호출
|
||||||
|
2. serverFetch() → 쿠키에서 access_token 읽기
|
||||||
|
3. authenticatedFetch() → Authorization 헤더에 토큰 추가
|
||||||
|
4. 백엔드 API 호출 → 응답 반환
|
||||||
|
```
|
||||||
|
|
||||||
|
### 토큰 만료 시 (401 자동 갱신)
|
||||||
|
|
||||||
|
```
|
||||||
|
1. API 요청 → 401 응답 수신
|
||||||
|
2. authenticatedFetch() → refresh_token으로 갱신 요청
|
||||||
|
3. 새 토큰 수신 → 쿠키 업데이트
|
||||||
|
4. 원래 요청 재시도 → 성공
|
||||||
|
5. 갱신 실패 → 쿠키 삭제 → /login 리다이렉트
|
||||||
|
```
|
||||||
|
|
||||||
|
### 토큰 갱신 중복 방지
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// globalThis 레벨 캐싱 (5초)
|
||||||
|
// 여러 요청이 동시에 401을 받아도 refresh는 1회만 실행
|
||||||
|
// 진행 중인 refresh Promise를 공유하여 대기
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 프록시 (`/api/proxy/[...path]`)
|
||||||
|
|
||||||
|
클라이언트에서 직접 백엔드 API를 호출해야 하는 경우 프록시 사용:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 클라이언트에서 프록시 호출
|
||||||
|
const response = await fetch('/api/proxy/item-master/init');
|
||||||
|
const data = await response.json();
|
||||||
|
```
|
||||||
|
|
||||||
|
프록시 내부 동작:
|
||||||
|
1. HttpOnly 쿠키에서 `access_token` 읽기
|
||||||
|
2. 백엔드 URL 구성 (`/api/proxy/*` → 백엔드 `/*`)
|
||||||
|
3. `Authorization: Bearer {token}` 헤더 추가
|
||||||
|
4. 요청 전달 → 응답 반환
|
||||||
|
5. 401 시 자동 토큰 갱신 후 재시도
|
||||||
|
6. 새 토큰 → Set-Cookie 헤더로 클라이언트에 전달
|
||||||
|
|
||||||
|
## 인증 보호
|
||||||
|
|
||||||
|
### Protected Layout
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// (protected)/layout.tsx
|
||||||
|
export default function ProtectedLayout({ children }) {
|
||||||
|
// 인증 가드 (뒤로가기 캐시 감지)
|
||||||
|
useAuthGuard();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<RootProvider>
|
||||||
|
<ApiErrorProvider> {/* 401 에러 자동 처리 */}
|
||||||
|
<FCMProvider> {/* 푸시 알림 */}
|
||||||
|
<AuthenticatedLayout>
|
||||||
|
<PermissionGate> {/* 권한 기반 접근 제어 */}
|
||||||
|
{children}
|
||||||
|
</PermissionGate>
|
||||||
|
</AuthenticatedLayout>
|
||||||
|
</FCMProvider>
|
||||||
|
</ApiErrorProvider>
|
||||||
|
</RootProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인증 상태 확인 (클라이언트)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { hasAuthToken } from '@/lib/api/auth-headers';
|
||||||
|
|
||||||
|
// is_authenticated 쿠키 확인 (non-HttpOnly)
|
||||||
|
if (hasAuthToken()) {
|
||||||
|
// 인증됨
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 로그아웃
|
||||||
|
|
||||||
|
완전한 로그아웃 절차:
|
||||||
|
1. Zustand 스토어 초기화 (useAuthStore, useMasterDataStore, useItemMasterStore)
|
||||||
|
2. sessionStorage 캐시 삭제 (page_config_*, mes-*)
|
||||||
|
3. localStorage 사용자 데이터 삭제
|
||||||
|
4. FCM 토큰 해제 (Capacitor 환경)
|
||||||
|
5. 서버 로그아웃 API 호출
|
||||||
|
6. /login 리다이렉트
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
- **Server Component에서 쿠키 수정 불가** → Client Component 사용 필수
|
||||||
|
- **`alert()`, `confirm()`, `prompt()` 사용 금지** → Radix UI Dialog 또는 `toast` 사용
|
||||||
|
- **API 직접 호출 금지** → 반드시 Server Action 또는 프록시 사용
|
||||||
245
sam-docs/frontend/v1/04-server-actions.md
Normal file
245
sam-docs/frontend/v1/04-server-actions.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Server Action 패턴
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
모든 백엔드 API 호출은 Server Action을 통해 처리합니다. 공통 유틸리티를 사용하여 보일러플레이트를 제거하고 일관된 패턴을 유지합니다.
|
||||||
|
|
||||||
|
## 핵심 유틸리티
|
||||||
|
|
||||||
|
### buildApiUrl - URL 빌더 (필수)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
|
|
||||||
|
// 기본 사용
|
||||||
|
buildApiUrl('/api/v1/items')
|
||||||
|
// → "https://api.example.com/api/v1/items"
|
||||||
|
|
||||||
|
// 쿼리 파라미터
|
||||||
|
buildApiUrl('/api/v1/items', {
|
||||||
|
search: 'test',
|
||||||
|
status: 'active',
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
// → "https://api.example.com/api/v1/items?search=test&status=active&page=1"
|
||||||
|
|
||||||
|
// undefined/null/'' 자동 필터링
|
||||||
|
buildApiUrl('/api/v1/items', {
|
||||||
|
search: '', // 제외됨
|
||||||
|
status: undefined, // 제외됨
|
||||||
|
page: 1,
|
||||||
|
})
|
||||||
|
// → "https://api.example.com/api/v1/items?page=1"
|
||||||
|
|
||||||
|
// 동적 경로 + 파라미터
|
||||||
|
buildApiUrl(`/api/v1/items/${id}`, { with_details: true })
|
||||||
|
```
|
||||||
|
|
||||||
|
> **금지**: `new URLSearchParams()` 직접 사용, `${API_URL}` 직접 조립
|
||||||
|
|
||||||
|
### executeServerAction - 단건/목록 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||||
|
|
||||||
|
const result = await executeServerAction<ApiType, FrontendType>({
|
||||||
|
url: buildApiUrl('/api/v1/items', { search: params.search }),
|
||||||
|
method: 'GET', // 기본값: GET
|
||||||
|
transform: (data) => ..., // snake_case → camelCase 변환
|
||||||
|
errorMessage: '조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 반환 타입
|
||||||
|
interface ActionResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
fieldErrors?: Record<string, string[]>; // Laravel validation errors
|
||||||
|
__authError?: boolean; // 401 감지
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### executePaginatedAction - 페이지네이션 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||||
|
|
||||||
|
const result = await executePaginatedAction<ApiType, FrontendType>({
|
||||||
|
url: buildApiUrl('/api/v1/items', {
|
||||||
|
search: params.search,
|
||||||
|
status: params.status !== 'all' ? params.status : undefined,
|
||||||
|
page: params.page,
|
||||||
|
}),
|
||||||
|
transform: transformApiToFrontend, // 개별 아이템 변환 함수
|
||||||
|
errorMessage: '목록 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 반환 타입
|
||||||
|
interface PaginatedActionResult<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T[]; // 변환된 아이템 배열
|
||||||
|
pagination: PaginationMeta; // 페이지네이션 정보
|
||||||
|
error?: string;
|
||||||
|
__authError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PaginationMeta {
|
||||||
|
currentPage: number;
|
||||||
|
lastPage: number;
|
||||||
|
perPage: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Action 작성 패턴
|
||||||
|
|
||||||
|
### 표준 예시
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/components/{domain}/actions.ts
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||||
|
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||||
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
|
|
||||||
|
// ===== 1. API 원본 타입 (snake_case) =====
|
||||||
|
interface ItemApi {
|
||||||
|
id: number;
|
||||||
|
item_name: string;
|
||||||
|
item_code: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 2. 프론트엔드 타입 (camelCase) =====
|
||||||
|
export interface Item {
|
||||||
|
id: string;
|
||||||
|
itemName: string;
|
||||||
|
itemCode: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 3. Transform 함수 =====
|
||||||
|
function transformItem(api: ItemApi): Item {
|
||||||
|
return {
|
||||||
|
id: String(api.id),
|
||||||
|
itemName: api.item_name,
|
||||||
|
itemCode: api.item_code,
|
||||||
|
createdAt: api.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 4. 목록 조회 (페이지네이션) =====
|
||||||
|
export async function getItems(params: {
|
||||||
|
search?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
}) {
|
||||||
|
return executePaginatedAction({
|
||||||
|
url: buildApiUrl('/api/v1/items', {
|
||||||
|
search: params.search,
|
||||||
|
status: params.status !== 'all' ? params.status : undefined,
|
||||||
|
page: params.page,
|
||||||
|
}),
|
||||||
|
transform: transformItem,
|
||||||
|
errorMessage: '품목 목록 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 5. 단건 조회 =====
|
||||||
|
export async function getItem(id: string) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/items/${id}`),
|
||||||
|
transform: (data: { item: ItemApi }) => transformItem(data.item),
|
||||||
|
errorMessage: '품목 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 6. 생성 =====
|
||||||
|
export async function createItem(formData: Partial<Item>) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/items'),
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
item_name: formData.itemName,
|
||||||
|
item_code: formData.itemCode,
|
||||||
|
},
|
||||||
|
errorMessage: '품목 등록에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 7. 수정 =====
|
||||||
|
export async function updateItem(id: string, formData: Partial<Item>) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/items/${id}`),
|
||||||
|
method: 'PUT',
|
||||||
|
body: {
|
||||||
|
item_name: formData.itemName,
|
||||||
|
item_code: formData.itemCode,
|
||||||
|
},
|
||||||
|
errorMessage: '품목 수정에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 8. 삭제 =====
|
||||||
|
export async function deleteItems(ids: string[]) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/items/bulk-delete'),
|
||||||
|
method: 'POST',
|
||||||
|
body: { ids: ids.map(Number) },
|
||||||
|
errorMessage: '품목 삭제에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 컴포넌트에서 Server Action 호출
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getItems, type Item } from '@/components/{domain}/actions';
|
||||||
|
|
||||||
|
export default function ItemList() {
|
||||||
|
const [data, setData] = useState<Item[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getItems({ page: 1 })
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) {
|
||||||
|
setData(result.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) return <div>로딩 중...</div>;
|
||||||
|
return <>{/* 렌더링 */}</>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 주의사항
|
||||||
|
|
||||||
|
### 'use server' 파일에서 타입 re-export 금지
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 금지 - Next.js Turbopack 제한 (async 함수만 export 허용)
|
||||||
|
export type { Item } from './types';
|
||||||
|
export { type Item } from './types';
|
||||||
|
|
||||||
|
// ✅ 허용 - 인라인 타입 정의
|
||||||
|
export interface Item { ... }
|
||||||
|
export type Item = { ... };
|
||||||
|
|
||||||
|
// ✅ 허용 - 컴포넌트에서 원본 타입 파일 직접 import
|
||||||
|
// 컴포넌트에서: import type { Item } from './types';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 변환 체인
|
||||||
|
|
||||||
|
```
|
||||||
|
Backend (snake_case) → safeResponseJson() → transform() → Frontend (camelCase)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `safeResponseJson`: PHP 백엔드가 JSON 뒤에 경고 텍스트를 붙여 보내는 경우 방어
|
||||||
|
- `transform`: snake_case → camelCase 변환 (개발자 작성)
|
||||||
346
sam-docs/frontend/v1/05-common-components.md
Normal file
346
sam-docs/frontend/v1/05-common-components.md
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
# 공통 컴포넌트 가이드
|
||||||
|
|
||||||
|
## 컴포넌트 계층 요약
|
||||||
|
|
||||||
|
```
|
||||||
|
Templates → 페이지 전체 (IntegratedListTemplateV2)
|
||||||
|
Organisms → 페이지 블록 (PageHeader, DataTable, SearchFilter ...)
|
||||||
|
Molecules → 조합 단위 (FormField, StatusBadge, StandardDialog ...)
|
||||||
|
UI → 원자 단위 (Button, Input, Select ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Templates
|
||||||
|
|
||||||
|
### IntegratedListTemplateV2
|
||||||
|
|
||||||
|
리스트 페이지를 위한 **올인원 템플릿**. 새 리스트 페이지 생성 시 이 템플릿 사용을 우선 검토합니다.
|
||||||
|
|
||||||
|
**경로**: `src/components/templates/IntegratedListTemplateV2.tsx`
|
||||||
|
|
||||||
|
**포함 기능**:
|
||||||
|
- PageLayout + PageHeader (아이콘/제목/설명)
|
||||||
|
- 검색 + 필터 + 날짜 선택 헤더
|
||||||
|
- 통계 카드 (StatCards)
|
||||||
|
- 테이블 + 컬럼 설정 + 페이지네이션
|
||||||
|
- 모바일 카드 자동 전환 (반응형)
|
||||||
|
- 체크박스 선택 (`Set<string>`)
|
||||||
|
|
||||||
|
**필수 적용 항목**:
|
||||||
|
1. 컬럼 설정 (`useColumnSettings` + `ColumnSettingsPopover`)
|
||||||
|
2. 모바일 카드 (`renderMobileCard`)
|
||||||
|
3. 체크박스 (`selectedItems: Set<string>`)
|
||||||
|
4. 테이블 내 필터 (`tableHeaderActions`)
|
||||||
|
|
||||||
|
**기본 사용법**:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import IntegratedListTemplateV2 from '@/components/templates/IntegratedListTemplateV2';
|
||||||
|
import { useColumnSettings } from '@/hooks/useColumnSettings';
|
||||||
|
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
||||||
|
import { MobileCard, InfoField } from '@/components/organisms';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'itemName', label: '품목명', width: '200px' },
|
||||||
|
{ key: 'itemCode', label: '품목코드', width: '150px' },
|
||||||
|
{ key: 'status', label: '상태', width: '100px' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function ItemListPage() {
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||||
|
const { visibleColumns, allColumnsWithVisibility, columnWidths, setColumnWidth,
|
||||||
|
toggleColumnVisibility, resetSettings, hasHiddenColumns } =
|
||||||
|
useColumnSettings({ pageId: 'item-list', columns });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IntegratedListTemplateV2
|
||||||
|
title="품목 관리"
|
||||||
|
icon={Package}
|
||||||
|
description="품목 목록을 관리합니다"
|
||||||
|
// 검색
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="품목명 또는 코드로 검색"
|
||||||
|
// 테이블
|
||||||
|
tableColumns={visibleColumns}
|
||||||
|
columnSettings={{
|
||||||
|
columnWidths,
|
||||||
|
onColumnResize: setColumnWidth,
|
||||||
|
settingsPopover: (
|
||||||
|
<ColumnSettingsPopover
|
||||||
|
columns={allColumnsWithVisibility}
|
||||||
|
onToggle={toggleColumnVisibility}
|
||||||
|
onReset={resetSettings}
|
||||||
|
hasHiddenColumns={hasHiddenColumns}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
data={items}
|
||||||
|
// 체크박스
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onToggleSelection={(id) => {
|
||||||
|
setSelectedItems(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onToggleSelectAll={() => { /* 전체 선택/해제 */ }}
|
||||||
|
getItemId={(item) => item.id}
|
||||||
|
// 테이블 행
|
||||||
|
renderTableRow={(item, index, globalIndex, isSelected, onToggle) => (
|
||||||
|
<tr key={item.id} className={isSelected ? 'bg-blue-50' : ''}>
|
||||||
|
<td><Checkbox checked={isSelected} onCheckedChange={onToggle} /></td>
|
||||||
|
<td>{globalIndex}</td>
|
||||||
|
<td>{item.itemName}</td>
|
||||||
|
<td>{item.itemCode}</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
// 모바일 카드 (반응형)
|
||||||
|
renderMobileCard={(item, index, globalIndex, isSelected, onToggle) => (
|
||||||
|
<MobileCard
|
||||||
|
title={item.itemName}
|
||||||
|
subtitle={item.itemCode}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggleSelection={onToggle}
|
||||||
|
details={[
|
||||||
|
{ label: '상태', value: item.status },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
// 페이지네이션
|
||||||
|
pagination={{
|
||||||
|
currentPage: pagination.currentPage,
|
||||||
|
totalPages: pagination.lastPage,
|
||||||
|
totalItems: pagination.total,
|
||||||
|
itemsPerPage: pagination.perPage,
|
||||||
|
onPageChange: (page) => fetchData({ page }),
|
||||||
|
}}
|
||||||
|
isLoading={isLoading}
|
||||||
|
// 등록 버튼
|
||||||
|
createButton={{ label: '품목 등록', onClick: () => router.push('?mode=new') }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Organisms
|
||||||
|
|
||||||
|
**경로**: `src/components/organisms/`
|
||||||
|
**import**: `import { PageHeader, DataTable, ... } from '@/components/organisms'`
|
||||||
|
|
||||||
|
### PageHeader
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PageHeader
|
||||||
|
title="품목 관리"
|
||||||
|
description="품목 목록을 관리합니다"
|
||||||
|
icon={Package}
|
||||||
|
actions={<Button onClick={handleCreate}>등록</Button>}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | 타입 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| `title` | string \| ReactNode | 페이지 제목 (필수) |
|
||||||
|
| `description?` | string | 부제목 |
|
||||||
|
| `icon?` | LucideIcon | 좌측 아이콘 |
|
||||||
|
| `actions?` | ReactNode | 우측 액션 버튼 |
|
||||||
|
|
||||||
|
### PageLayout
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<PageLayout maxWidth="full">
|
||||||
|
{children}
|
||||||
|
</PageLayout>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | 타입 | 기본값 | 설명 |
|
||||||
|
|------|------|--------|------|
|
||||||
|
| `maxWidth?` | "sm"\|"md"\|"lg"\|"xl"\|"2xl"\|"full" | "full" | 최대 너비 |
|
||||||
|
|
||||||
|
### StatCards
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StatCards stats={[
|
||||||
|
{ label: '전체', value: 100, icon: Package },
|
||||||
|
{ label: '활성', value: 80, icon: CheckCircle, iconColor: 'text-green-500' },
|
||||||
|
{ label: '비활성', value: 20, icon: XCircle, iconColor: 'text-red-500' },
|
||||||
|
]} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### SearchFilter
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SearchFilter
|
||||||
|
searchValue={search}
|
||||||
|
onSearchChange={setSearch}
|
||||||
|
searchPlaceholder="검색어 입력"
|
||||||
|
extraActions={<DatePicker value={date} onChange={setDate} />}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### DataTable
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'name', label: '이름', sortable: true },
|
||||||
|
{ key: 'status', label: '상태', type: 'badge' },
|
||||||
|
{ key: 'amount', label: '금액', type: 'currency', align: 'right' },
|
||||||
|
{ key: 'actions', label: '', type: 'custom',
|
||||||
|
render: (_, row) => <Button size="sm">수정</Button> },
|
||||||
|
]}
|
||||||
|
data={items}
|
||||||
|
keyField="id"
|
||||||
|
onRowClick={(row) => router.push(`/items/${row.id}`)}
|
||||||
|
pagination={{ currentPage, totalPages, onPageChange }}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Column type 종류**: `text`, `number`, `currency`, `date`, `datetime`, `status`, `badge`, `icon`, `actions`, `custom`
|
||||||
|
|
||||||
|
### SearchableSelectionModal
|
||||||
|
|
||||||
|
검색+선택 팝업이 필요할 때 사용. **직접 Dialog 조합 금지**.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<SearchableSelectionModal<Vendor>
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
title="거래처 검색"
|
||||||
|
fetchData={async (query) => {
|
||||||
|
const result = await searchVendors({ search: query });
|
||||||
|
return result.success ? result.data : [];
|
||||||
|
}}
|
||||||
|
keyExtractor={(vendor) => vendor.id}
|
||||||
|
mode="single"
|
||||||
|
onSelect={(vendor) => handleVendorSelect(vendor)}
|
||||||
|
searchPlaceholder="거래처명으로 검색"
|
||||||
|
renderItem={(vendor, isSelected) => (
|
||||||
|
<div className={cn('p-3', isSelected && 'bg-blue-50')}>
|
||||||
|
<div className="font-medium">{vendor.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{vendor.code}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Prop | 필수 | 설명 |
|
||||||
|
|------|:---:|------|
|
||||||
|
| `open` | O | 모달 열기 상태 |
|
||||||
|
| `onOpenChange` | O | 상태 변경 |
|
||||||
|
| `title` | O | 모달 제목 |
|
||||||
|
| `fetchData` | O | `(query: string) => Promise<T[]>` |
|
||||||
|
| `keyExtractor` | O | `(item: T) => string` |
|
||||||
|
| `mode` | O | `'single'` \| `'multiple'` |
|
||||||
|
| `onSelect` | O | 선택 콜백 |
|
||||||
|
| `renderItem` | O | 아이템 렌더링 |
|
||||||
|
| `searchMode?` | | `'debounce'`(기본) \| `'enter'` |
|
||||||
|
| `loadOnOpen?` | | 열릴 때 자동 로드 |
|
||||||
|
| `listWrapper?` | | 리스트 래퍼 (테이블 구조 등) |
|
||||||
|
|
||||||
|
### MobileCard / InfoField
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<MobileCard
|
||||||
|
title="품목A"
|
||||||
|
subtitle="P-001"
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggleSelection={onToggle}
|
||||||
|
details={[
|
||||||
|
{ label: '규격', value: '100x200' },
|
||||||
|
{ label: '단가', value: '10,000원' },
|
||||||
|
]}
|
||||||
|
onClick={() => router.push(`/items/${item.id}`)}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### EmptyState / TableEmptyState
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<EmptyState message="데이터가 없습니다" />
|
||||||
|
<TableEmptyState colSpan={5} message="검색 결과가 없습니다" />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Molecules
|
||||||
|
|
||||||
|
**경로**: `src/components/molecules/`
|
||||||
|
|
||||||
|
### FormField (신규 폼 필수)
|
||||||
|
|
||||||
|
`Label + Input + Error` 수동 조합 대신 사용.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { FormField } from '@/components/molecules/FormField';
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
label="회사명"
|
||||||
|
required
|
||||||
|
type="text"
|
||||||
|
value={formData.companyName}
|
||||||
|
onChange={(value) => handleChange('companyName', value)}
|
||||||
|
placeholder="회사명을 입력하세요"
|
||||||
|
disabled={mode === 'view'}
|
||||||
|
error={errors.companyName}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**지원 type**: `text`, `number`, `date`, `select`, `textarea`, `custom`, `password`, `phone`, `businessNumber`, `personalNumber`, `currency`, `quantity`
|
||||||
|
|
||||||
|
**FormField로 대체하지 않는 경우**:
|
||||||
|
- Select, DatePicker, ImageUpload 등 특수 컴포넌트
|
||||||
|
- 주소 검색(버튼+입력) 등 복합 레이아웃
|
||||||
|
- 편집/읽기 모드가 다른 커스텀 인터랙션
|
||||||
|
|
||||||
|
### StatusBadge
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { StatusBadge } from '@/components/molecules/StatusBadge';
|
||||||
|
|
||||||
|
<StatusBadge label="승인" variant="success" />
|
||||||
|
<StatusBadge label="대기" variant="warning" showDot />
|
||||||
|
<StatusBadge label="반려" variant="danger" />
|
||||||
|
```
|
||||||
|
|
||||||
|
**variant**: `default`, `success`, `warning`, `danger`, `info`, `secondary`, `outline`
|
||||||
|
|
||||||
|
### ColumnSettingsPopover
|
||||||
|
|
||||||
|
`useColumnSettings` hook과 함께 사용:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ColumnSettingsPopover
|
||||||
|
columns={allColumnsWithVisibility}
|
||||||
|
onToggle={toggleColumnVisibility}
|
||||||
|
onReset={resetSettings}
|
||||||
|
hasHiddenColumns={hasHiddenColumns}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### StandardDialog
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StandardDialog
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
title="확인"
|
||||||
|
description="정말 삭제하시겠습니까?"
|
||||||
|
size="md"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setIsOpen(false)}>취소</Button>
|
||||||
|
<Button variant="destructive" onClick={handleDelete}>삭제</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p>이 작업은 되돌릴 수 없습니다.</p>
|
||||||
|
</StandardDialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
**size**: `sm`, `md`, `lg`, `xl`, `full`
|
||||||
176
sam-docs/frontend/v1/06-ui-components.md
Normal file
176
sam-docs/frontend/v1/06-ui-components.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# UI 컴포넌트 카탈로그
|
||||||
|
|
||||||
|
**경로**: `src/components/ui/`
|
||||||
|
**기반**: shadcn/ui (Radix UI + Tailwind CSS)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 입력 컴포넌트
|
||||||
|
|
||||||
|
### 기본 입력
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 용도 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `Input` | `input.tsx` | 텍스트 입력 |
|
||||||
|
| `Textarea` | `textarea.tsx` | 여러 줄 텍스트 |
|
||||||
|
| `Checkbox` | `checkbox.tsx` | 체크박스 |
|
||||||
|
| `RadioGroup` | `radio-group.tsx` | 라디오 버튼 |
|
||||||
|
| `Switch` | `switch.tsx` | 토글 스위치 |
|
||||||
|
| `Slider` | `slider.tsx` | 슬라이더 |
|
||||||
|
| `Select` | `select.tsx` | 셀렉트 (Radix UI) |
|
||||||
|
|
||||||
|
### 특화 입력
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 용도 | 특징 |
|
||||||
|
|----------|------|------|------|
|
||||||
|
| `DatePicker` | `date-picker.tsx` | 날짜 선택 | 한글 locale, 주말/휴일 색상, 연/월 선택, "오늘" 버튼 |
|
||||||
|
| `DateRangePicker` | `date-range-picker.tsx` | 기간 선택 | 시작~종료 날짜 |
|
||||||
|
| `DateTimePicker` | `date-time-picker.tsx` | 날짜+시간 | |
|
||||||
|
| `TimePicker` | `time-picker.tsx` | 시간만 | |
|
||||||
|
| `PhoneInput` | `phone-input.tsx` | 전화번호 | 자동 하이픈 (010-1234-5678) |
|
||||||
|
| `BusinessNumberInput` | `business-number-input.tsx` | 사업자번호 | 자동 포맷 (000-00-00000) |
|
||||||
|
| `PersonalNumberInput` | `personal-number-input.tsx` | 주민번호 | 마스킹 가능 |
|
||||||
|
| `CardNumberInput` | `card-number-input.tsx` | 카드번호 | 4자리 구분 |
|
||||||
|
| `AccountNumberInput` | `account-number-input.tsx` | 계좌번호 | |
|
||||||
|
| `NumberInput` | `number-input.tsx` | 숫자 | |
|
||||||
|
| `CurrencyInput` | `currency-input.tsx` | 금액 | 천단위 콤마, ₩ 접두사 |
|
||||||
|
| `QuantityInput` | `quantity-input.tsx` | 수량 | +/- 버튼 |
|
||||||
|
| `FileInput` | `file-input.tsx` | 파일 | |
|
||||||
|
| `FileDropzone` | `file-dropzone.tsx` | 파일 드래그 앤 드롭 | |
|
||||||
|
| `ImageUpload` | `image-upload.tsx` | 이미지 업로드 | 미리보기 |
|
||||||
|
|
||||||
|
### 검색/선택
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 용도 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `SearchableSelect` | `searchable-select.tsx` | 검색 가능 셀렉트 |
|
||||||
|
| `MultiSelectCombobox` | `multi-select-combobox.tsx` | 다중 선택 콤보박스 |
|
||||||
|
| `Command` | `command.tsx` | 검색/필터 커맨드 팔레트 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 피드백 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 용도 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `Button` | `button.tsx` | 버튼 (variant: default, destructive, outline, secondary, ghost, link) |
|
||||||
|
| `Badge` | `badge.tsx` | 뱃지 |
|
||||||
|
| `Alert` | `alert.tsx` | 알림 |
|
||||||
|
| `AlertDialog` | `alert-dialog.tsx` | 알림 다이얼로그 |
|
||||||
|
| `ConfirmDialog` | `confirm-dialog.tsx` | 확인 다이얼로그 |
|
||||||
|
| `ErrorCard` | `error-card.tsx` | 에러 카드 |
|
||||||
|
| `ErrorMessage` | `error-message.tsx` | 에러 메시지 |
|
||||||
|
| `LoadingSpinner` | `loading-spinner.tsx` | 로딩 스피너 |
|
||||||
|
| `Skeleton` | `skeleton.tsx` | 스켈레톤 (로딩 플레이스홀더) |
|
||||||
|
| `toast` | `sonner` 라이브러리 | 토스트 알림 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 레이아웃 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 용도 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `Card` | `card.tsx` | 카드 (CardHeader, CardContent, CardFooter) |
|
||||||
|
| `Dialog` | `dialog.tsx` | 다이얼로그 (모달) |
|
||||||
|
| `Drawer` | `drawer.tsx` | 드로어 (하단/측면 패널) |
|
||||||
|
| `Popover` | `popover.tsx` | 팝오버 |
|
||||||
|
| `Sheet` | `sheet.tsx` | 시트 (측면 패널) |
|
||||||
|
| `Accordion` | `accordion.tsx` | 아코디언 (접기/펼치기) |
|
||||||
|
| `Tabs` | `tabs.tsx` | 탭 |
|
||||||
|
| `Table` | `table.tsx` | 테이블 (Table, TableHeader, TableBody, TableRow, TableCell) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 기타 컴포넌트
|
||||||
|
|
||||||
|
| 컴포넌트 | 파일 | 용도 |
|
||||||
|
|----------|------|------|
|
||||||
|
| `Label` | `label.tsx` | 라벨 |
|
||||||
|
| `Separator` | `separator.tsx` | 구분선 |
|
||||||
|
| `ScrollArea` | `scroll-area.tsx` | 스크롤 영역 |
|
||||||
|
| `Tooltip` | `tooltip.tsx` | 툴팁 |
|
||||||
|
| `Progress` | `progress.tsx` | 진행률 바 |
|
||||||
|
| `FileList` | `file-list.tsx` | 파일 목록 표시 |
|
||||||
|
| `ChartWrapper` | `chart-wrapper.tsx` | 차트 래퍼 (Recharts) |
|
||||||
|
| `EmptyState` | `empty-state.tsx` | 빈 상태 표시 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DatePicker 사용법
|
||||||
|
|
||||||
|
프로젝트 전체에서 `<input type="date">` 대신 사용.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
|
||||||
|
// 기본
|
||||||
|
<DatePicker
|
||||||
|
value={date} // "yyyy-MM-dd" 문자열
|
||||||
|
onChange={(date) => setDate(date)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// 옵션
|
||||||
|
<DatePicker
|
||||||
|
value={date}
|
||||||
|
onChange={setDate}
|
||||||
|
placeholder="날짜 선택"
|
||||||
|
size="sm" // "default" | "sm" | "lg"
|
||||||
|
disabled={!isEditMode}
|
||||||
|
minDate={new Date('2024-01-01')}
|
||||||
|
maxDate={new Date()}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props**:
|
||||||
|
- `value`: `string` (yyyy-MM-dd 형식)
|
||||||
|
- `onChange`: `(date: string) => void`
|
||||||
|
- `size?`: `"default"` | `"sm"` | `"lg"`
|
||||||
|
- `disabled?`, `placeholder?`, `className?`
|
||||||
|
- `minDate?`, `maxDate?`: `Date` 타입 (**문자열 아님**)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Radix UI Select 주의사항
|
||||||
|
|
||||||
|
빈 값('')으로 마운트 후 value 변경이 반영 안 되는 버그:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ key prop으로 강제 리마운트
|
||||||
|
<Select
|
||||||
|
key={`${fieldKey}-${stringValue}`}
|
||||||
|
value={stringValue}
|
||||||
|
onValueChange={onChange}
|
||||||
|
>
|
||||||
|
{/* options */}
|
||||||
|
</Select>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 팝업 정책
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ 금지: alert(), confirm(), prompt()
|
||||||
|
✅ 사용: AlertDialog, ConfirmDialog, toast (sonner)
|
||||||
|
```
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// 토스트
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
toast.success('저장되었습니다');
|
||||||
|
toast.error('오류가 발생했습니다');
|
||||||
|
|
||||||
|
// 확인 다이얼로그
|
||||||
|
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>삭제 확인</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>정말 삭제하시겠습니까?</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete}>삭제</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
```
|
||||||
186
sam-docs/frontend/v1/07-hooks.md
Normal file
186
sam-docs/frontend/v1/07-hooks.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
# 공통 Hooks
|
||||||
|
|
||||||
|
**경로**: `src/hooks/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 리스트 페이지 관련
|
||||||
|
|
||||||
|
### useColumnSettings
|
||||||
|
|
||||||
|
테이블 컬럼 표시/숨기기 및 너비 관리. `IntegratedListTemplateV2`와 함께 사용.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useColumnSettings } from '@/hooks/useColumnSettings';
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'name', label: '이름', width: '200px' },
|
||||||
|
{ key: 'code', label: '코드', width: '150px' },
|
||||||
|
{ key: 'status', label: '상태', width: '100px' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const {
|
||||||
|
visibleColumns, // 현재 표시되는 컬럼
|
||||||
|
allColumnsWithVisibility, // 전체 컬럼 (visibility/locked 포함)
|
||||||
|
columnWidths, // 컬럼 너비 맵
|
||||||
|
setColumnWidth, // 컬럼 너비 변경
|
||||||
|
toggleColumnVisibility, // 컬럼 표시/숨기기 토글
|
||||||
|
resetSettings, // 초기화
|
||||||
|
hasHiddenColumns, // 숨겨진 컬럼 존재 여부
|
||||||
|
} = useColumnSettings({
|
||||||
|
pageId: 'item-list', // Zustand 저장 키 (고유)
|
||||||
|
columns,
|
||||||
|
alwaysVisibleKeys: ['name'], // 항상 표시되는 컬럼 (숨기기 불가)
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### useListHandlers
|
||||||
|
|
||||||
|
리스트 페이지 검색, 필터, 페이지네이션 핸들러 통합.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useListHandlers } from '@/hooks/useListHandlers';
|
||||||
|
|
||||||
|
const { search, setSearch, pagination, handlePageChange, handleSearch } = useListHandlers({
|
||||||
|
initialSearch: '',
|
||||||
|
fetchData: getItems,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### useCRUDHandlers
|
||||||
|
|
||||||
|
생성, 수정, 삭제 핸들러 통합.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCRUDHandlers } from '@/hooks/useCRUDHandlers';
|
||||||
|
|
||||||
|
const { handleCreate, handleUpdate, handleDelete, isSubmitting } = useCRUDHandlers({
|
||||||
|
createFn: createItem,
|
||||||
|
updateFn: updateItem,
|
||||||
|
deleteFn: deleteItems,
|
||||||
|
onSuccess: () => fetchData(),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### useDeleteDialog
|
||||||
|
|
||||||
|
삭제 확인 다이얼로그 상태 관리.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDeleteDialog } from '@/hooks/useDeleteDialog';
|
||||||
|
|
||||||
|
const { isOpen, openDialog, closeDialog, confirmDelete, targetId } = useDeleteDialog({
|
||||||
|
onConfirm: async (id) => {
|
||||||
|
await deleteItem(id);
|
||||||
|
fetchData();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 상세 페이지 관련
|
||||||
|
|
||||||
|
### useDetailPageState
|
||||||
|
|
||||||
|
상세/수정/등록 페이지의 모드 및 상태 관리.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDetailPageState } from '@/hooks/useDetailPageState';
|
||||||
|
|
||||||
|
const { mode, isEditMode, isNewMode, isViewMode } = useDetailPageState();
|
||||||
|
```
|
||||||
|
|
||||||
|
### useDetailData
|
||||||
|
|
||||||
|
상세 데이터 비동기 로드.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDetailData } from '@/hooks/useDetailData';
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useDetailData({
|
||||||
|
id: params.id,
|
||||||
|
fetchFn: getItem,
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 데이터 관련
|
||||||
|
|
||||||
|
### useCommonCodes
|
||||||
|
|
||||||
|
공통 코드 조회 (상태, 분류 등).
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCommonCodes } from '@/hooks/useCommonCodes';
|
||||||
|
|
||||||
|
const { codes, isLoading } = useCommonCodes('item_status');
|
||||||
|
// codes: [{ id: 'active', name: '활성' }, { id: 'inactive', name: '비활성' }]
|
||||||
|
```
|
||||||
|
|
||||||
|
### useClientList
|
||||||
|
|
||||||
|
거래처 목록 조회.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useClientList } from '@/hooks/useClientList';
|
||||||
|
|
||||||
|
const { clients, isLoading } = useClientList();
|
||||||
|
```
|
||||||
|
|
||||||
|
### useItemList
|
||||||
|
|
||||||
|
품목 목록 조회.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useItemList } from '@/hooks/useItemList';
|
||||||
|
|
||||||
|
const { items, isLoading } = useItemList();
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 유틸리티 관련
|
||||||
|
|
||||||
|
### useDateRange
|
||||||
|
|
||||||
|
날짜 범위 상태 관리.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDateRange } from '@/hooks/useDateRange';
|
||||||
|
|
||||||
|
const { startDate, endDate, setStartDate, setEndDate, reset } = useDateRange({
|
||||||
|
defaultStart: '2024-01-01',
|
||||||
|
defaultEnd: '2024-12-31',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### usePermission
|
||||||
|
|
||||||
|
권한 기반 접근 제어.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { usePermission } from '@/hooks/usePermission';
|
||||||
|
|
||||||
|
const { canRead, canWrite, canDelete } = usePermission('item_master');
|
||||||
|
|
||||||
|
if (!canWrite) {
|
||||||
|
return <div>수정 권한이 없습니다.</div>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### useDaumPostcode
|
||||||
|
|
||||||
|
다음 우편번호 API 연동.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||||
|
|
||||||
|
const { openPostcode } = useDaumPostcode({
|
||||||
|
onComplete: (data) => {
|
||||||
|
setAddress(data.address);
|
||||||
|
setZipCode(data.zonecode);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
175
sam-docs/frontend/v1/08-utilities.md
Normal file
175
sam-docs/frontend/v1/08-utilities.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# 유틸리티 함수
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## cn - 클래스명 병합
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// clsx + tailwind-merge
|
||||||
|
<div className={cn('p-4 bg-white', isActive && 'bg-blue-50', className)} />
|
||||||
|
```
|
||||||
|
|
||||||
|
## safeJsonParse - 안전한 JSON 파싱
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { safeJsonParse } from '@/lib/utils';
|
||||||
|
|
||||||
|
const data = safeJsonParse<Config>(localStorage.getItem('config'), defaultConfig);
|
||||||
|
// 파싱 실패 시 fallback 반환
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 포맷팅 함수
|
||||||
|
|
||||||
|
**경로**: `src/lib/formatters.ts`
|
||||||
|
|
||||||
|
### 전화번호
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatPhoneNumber, parsePhoneNumber } from '@/lib/formatters';
|
||||||
|
|
||||||
|
formatPhoneNumber('01012345678') // → "010-1234-5678"
|
||||||
|
parsePhoneNumber('010-1234-5678') // → "01012345678" (숫자만)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 사업자번호
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatBusinessNumber, validateBusinessNumber } from '@/lib/formatters';
|
||||||
|
|
||||||
|
formatBusinessNumber('1234567890') // → "123-45-67890"
|
||||||
|
validateBusinessNumber('1234567890') // → true/false (체크섬 검증)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주민번호
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatPersonalNumber, formatPersonalNumberMasked } from '@/lib/formatters';
|
||||||
|
|
||||||
|
formatPersonalNumber('9001011234567') // → "900101-1234567"
|
||||||
|
formatPersonalNumberMasked('9001011234567') // → "900101-*******"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 카드/계좌번호
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatCardNumber, formatAccountNumber } from '@/lib/formatters';
|
||||||
|
|
||||||
|
formatCardNumber('1234567890123456') // → "1234-5678-9012-3456"
|
||||||
|
formatAccountNumber('12345678901234') // → "1234-5678-9012-34"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 숫자/금액
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { formatNumber, parseNumber, extractDigits } from '@/lib/formatters';
|
||||||
|
|
||||||
|
formatNumber(1234567) // → "1,234,567"
|
||||||
|
parseNumber('1,234,567') // → 1234567
|
||||||
|
extractDigits('abc-123-def') // → "123"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL 빌더
|
||||||
|
|
||||||
|
**경로**: `src/lib/api/query-params.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { buildApiUrl, buildQueryParams } from '@/lib/api/query-params';
|
||||||
|
|
||||||
|
// API URL 생성 (undefined/null/'' 자동 필터링)
|
||||||
|
const url = buildApiUrl('/api/v1/items', {
|
||||||
|
search: 'test',
|
||||||
|
status: undefined, // 제외
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 쿼리 파라미터만 생성
|
||||||
|
const params = buildQueryParams({ search: 'test', page: 1 });
|
||||||
|
// → URLSearchParams 객체
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인쇄 유틸리티
|
||||||
|
|
||||||
|
**경로**: `src/lib/print-utils.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { printElement, printArea } from '@/lib/print-utils';
|
||||||
|
|
||||||
|
// 특정 요소 인쇄
|
||||||
|
printElement(document.getElementById('invoice'));
|
||||||
|
|
||||||
|
// .print-area 클래스 영역 인쇄
|
||||||
|
printArea({ title: '견적서' });
|
||||||
|
|
||||||
|
// 옵션
|
||||||
|
printElement('#invoice', {
|
||||||
|
title: '견적서', // 브라우저 탭 제목
|
||||||
|
styles: customCSS, // 추가 CSS
|
||||||
|
closeAfterPrint: true, // 인쇄 후 창 닫기
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**HTML에서 사용**:
|
||||||
|
```tsx
|
||||||
|
<div className="print-area">
|
||||||
|
{/* 인쇄될 영역 */}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 인증 헤더
|
||||||
|
|
||||||
|
**경로**: `src/lib/api/auth-headers.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getAuthHeaders, getMultipartHeaders, hasAuthToken } from '@/lib/api/auth-headers';
|
||||||
|
|
||||||
|
// JSON 요청 헤더 (프록시 사용 시)
|
||||||
|
const headers = getAuthHeaders();
|
||||||
|
// → { 'Content-Type': 'application/json', 'Accept': 'application/json' }
|
||||||
|
|
||||||
|
// Multipart FormData 헤더
|
||||||
|
const headers = getMultipartHeaders();
|
||||||
|
// → { 'Accept': 'application/json' }
|
||||||
|
|
||||||
|
// 인증 상태 확인 (클라이언트)
|
||||||
|
if (hasAuthToken()) { /* 인증됨 */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 에러 처리
|
||||||
|
|
||||||
|
**경로**: `src/lib/api/errors.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createErrorResponse, isApiError, isAuthError } from '@/lib/api/errors';
|
||||||
|
|
||||||
|
// 에러 응답 생성
|
||||||
|
const error = createErrorResponse(404, '데이터를 찾을 수 없습니다');
|
||||||
|
|
||||||
|
// 에러 타입 확인
|
||||||
|
if (isApiError(response)) { /* API 에러 */ }
|
||||||
|
if (isAuthError(response)) { /* 인증 에러 (401) */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## localStorage 접근 (Next.js 호환)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Next.js Pattern (SSR 안전)
|
||||||
|
const [data, setData] = useState(() => {
|
||||||
|
if (typeof window === 'undefined') return defaultValue;
|
||||||
|
const saved = localStorage.getItem('key');
|
||||||
|
return saved ? JSON.parse(saved) : defaultValue;
|
||||||
|
});
|
||||||
|
```
|
||||||
205
sam-docs/frontend/v1/09-coding-conventions.md
Normal file
205
sam-docs/frontend/v1/09-coding-conventions.md
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
# 코딩 컨벤션 및 필수 규칙
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client Component 필수
|
||||||
|
|
||||||
|
모든 페이지는 `'use client'` 선언 필수. Server Component 사용 금지.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 올바른 패턴
|
||||||
|
'use client';
|
||||||
|
export default function Page() { ... }
|
||||||
|
|
||||||
|
// ❌ 금지
|
||||||
|
export default async function Page() { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**이유**: 폐쇄형 ERP (SEO 불필요), Server Component에서 쿠키 수정(토큰 갱신) 불가
|
||||||
|
|
||||||
|
## 데이터 로딩 패턴
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
'use client';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getData } from '@/components/.../actions';
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
getData()
|
||||||
|
.then(result => {
|
||||||
|
if (result.success) setData(result.data);
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (isLoading) return <div>로딩 중...</div>;
|
||||||
|
return <Component data={data} />;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## buildApiUrl 필수 사용
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 필수
|
||||||
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
|
const url = buildApiUrl('/api/v1/items', { search, page });
|
||||||
|
|
||||||
|
// ❌ 금지
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('search', value);
|
||||||
|
const url = `${API_URL}/api/v1/items?${params.toString()}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 컴포넌트 재사용 우선
|
||||||
|
|
||||||
|
새 컴포넌트 작성 전 확인 순서:
|
||||||
|
1. `src/components/organisms/index.ts` export 목록
|
||||||
|
2. `src/components/molecules/` 내 공통 컴포넌트
|
||||||
|
3. `src/components/ui/` 내 UI 컴포넌트
|
||||||
|
4. dev/component-registry 페이지 검색
|
||||||
|
5. 동일 도메인 기존 컴포넌트
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FormField 사용 (신규 폼)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// ✅ 신규 폼 - FormField 사용
|
||||||
|
<FormField label="회사명" value={v} onChange={handleChange} />
|
||||||
|
|
||||||
|
// ❌ 신규 폼에서 수동 조합 금지
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>회사명</Label>
|
||||||
|
<Input value={v} onChange={handleChange} />
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**기존 폼**: 건드리지 않음 (정상 작동 중이면 마이그레이션 불필요)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zod 스키마 검증 (신규 폼)
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
|
||||||
|
// 1. 스키마 정의
|
||||||
|
const formSchema = z.object({
|
||||||
|
itemName: z.string().min(1, '품목명을 입력하세요'),
|
||||||
|
quantity: z.number().min(1, '1 이상 입력하세요'),
|
||||||
|
status: z.enum(['active', 'inactive']),
|
||||||
|
memo: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. 타입 추출 (별도 interface 정의 불필요)
|
||||||
|
type FormData = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
// 3. useForm에 연결
|
||||||
|
const form = useForm<FormData>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: { itemName: '', quantity: 1, status: 'active' },
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**규칙**:
|
||||||
|
- 에러 메시지 한글 작성
|
||||||
|
- 스키마 위치: 컴포넌트 파일 상단 또는 `schema.ts`
|
||||||
|
- `z.infer` 사용, 별도 `interface` 중복 정의 금지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 팝업 정책
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ 금지: alert(), confirm(), prompt()
|
||||||
|
✅ 사용: Radix UI Dialog/AlertDialog, toast (sonner)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 검색 모달 표준
|
||||||
|
|
||||||
|
```
|
||||||
|
❌ 금지: Dialog + Input + 리스트 직접 조합
|
||||||
|
✅ 사용: SearchableSelectionModal<T>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 리스트 페이지 필수 항목
|
||||||
|
|
||||||
|
`IntegratedListTemplateV2` 사용 시:
|
||||||
|
- [ ] `useColumnSettings` + `ColumnSettingsPopover` 적용
|
||||||
|
- [ ] `renderMobileCard` (모바일 카드) 구현
|
||||||
|
- [ ] `selectedItems: Set<string>` (체크박스) 구현
|
||||||
|
- [ ] `tableHeaderActions` (테이블 내 필터) 필요 시 구현
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 테이블 rowSpan/colSpan (문서/보고서)
|
||||||
|
|
||||||
|
**반드시 구조 분석 → 코딩 순서**:
|
||||||
|
|
||||||
|
1. **플랫 인덱스 맵**: 실제 렌더링 행 수 기준으로 인덱스 산정
|
||||||
|
2. **병합 범위 표기**: span은 그룹 첫 행에만
|
||||||
|
3. **Coverage Map 패턴**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function buildCoverageMap(items, spanKey) {
|
||||||
|
const map = {};
|
||||||
|
const covered = new Set();
|
||||||
|
items.forEach((item, idx) => {
|
||||||
|
const span = item[spanKey];
|
||||||
|
if (span && span > 1) {
|
||||||
|
map[idx] = span;
|
||||||
|
for (let i = idx + 1; i < idx + span; i++) covered.add(i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { map, covered };
|
||||||
|
}
|
||||||
|
// map에 있으면 → <td rowSpan={span}>
|
||||||
|
// covered에 있으면 → skip (렌더링 안 함)
|
||||||
|
// 둘 다 아니면 → 일반 <td>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git 규칙
|
||||||
|
|
||||||
|
- **develop**: 평소 작업 (자유롭게 커밋)
|
||||||
|
- **main**: 기능별 squash merge만 (직접 push 금지)
|
||||||
|
- **커밋 메시지**: `[타입]: 작업내용` (feat, fix, chore, refactor 등)
|
||||||
|
- **`snapshot.txt`, `.DS_Store`**: 항상 제외
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 빌드 정책
|
||||||
|
|
||||||
|
- 개발자가 직접 빌드 확인
|
||||||
|
- TypeScript strict 모드 사용
|
||||||
|
- ESLint: 빌드 시 무시 (CI에서 별도 처리)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 신규 페이지 생성 체크리스트
|
||||||
|
|
||||||
|
- [ ] `'use client'` 선언
|
||||||
|
- [ ] `?mode=new/edit` 쿼리파라미터 패턴 사용 (`/new`, `/edit` 경로 금지)
|
||||||
|
- [ ] Server Action에서 `buildApiUrl()` 사용
|
||||||
|
- [ ] 기존 컴포넌트 재사용 확인 (organisms, molecules 검색)
|
||||||
|
- [ ] 리스트 페이지: `IntegratedListTemplateV2` 사용 검토
|
||||||
|
- [ ] 폼 페이지: FormField, Zod 스키마 사용 (신규)
|
||||||
|
- [ ] 검색 모달: `SearchableSelectionModal` 사용
|
||||||
|
- [ ] 하단 sticky 액션 바 구현
|
||||||
|
- [ ] 모바일 반응형 대응
|
||||||
|
- [ ] 타입 체크 (`npx tsc --noEmit`)
|
||||||
46
scripts/patch-json-parse.cjs
Normal file
46
scripts/patch-json-parse.cjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* JSON.parse 글로벌 패치 - macOS 26 파일시스템 손상 대응
|
||||||
|
*
|
||||||
|
* macOS 26에서 atomic write(tmp + rename)가 실패하면
|
||||||
|
* .next/prerender-manifest.json 등의 파일에 데이터가 중복 기록됨.
|
||||||
|
* 이로 인해 "Unexpected non-whitespace character after JSON at position N" 발생.
|
||||||
|
*
|
||||||
|
* 이 패치는 JSON.parse 실패 시 유효한 JSON 부분만 추출하여 자동 복구.
|
||||||
|
* NODE_OPTIONS='--require ./scripts/patch-json-parse.cjs' 로 로드.
|
||||||
|
*/
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const originalParse = JSON.parse;
|
||||||
|
|
||||||
|
JSON.parse = function patchedJsonParse(text, reviver) {
|
||||||
|
try {
|
||||||
|
return originalParse.call(this, text, reviver);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof SyntaxError && typeof text === 'string') {
|
||||||
|
// "Unexpected non-whitespace character after JSON at position N"
|
||||||
|
// → position N까지가 유효한 JSON
|
||||||
|
const match = e.message.match(/after JSON at position\s+(\d+)/);
|
||||||
|
if (match) {
|
||||||
|
const pos = parseInt(match[1], 10);
|
||||||
|
if (pos > 0) {
|
||||||
|
try {
|
||||||
|
const result = originalParse.call(this, text.substring(0, pos), reviver);
|
||||||
|
// 한 번만 경고 (같은 position이면 반복 출력 방지)
|
||||||
|
if (!patchedJsonParse._warned) patchedJsonParse._warned = new Set();
|
||||||
|
const key = pos + ':' + text.length;
|
||||||
|
if (!patchedJsonParse._warned.has(key)) {
|
||||||
|
patchedJsonParse._warned.add(key);
|
||||||
|
console.warn(
|
||||||
|
`[patch-json-parse] macOS 파일 손상 자동 복구 (position ${pos}, total ${text.length} bytes)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
// truncation으로도 실패하면 원래 에러 throw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
49
scripts/validate-next-cache.mjs
Normal file
49
scripts/validate-next-cache.mjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* .next 빌드 캐시 무결성 검증
|
||||||
|
*
|
||||||
|
* macOS 26 파일시스템 이슈로 .next/ 내 JSON 파일이 손상될 수 있음.
|
||||||
|
* (atomic write 실패 → 데이터 중복 기록)
|
||||||
|
* dev 서버 시작 전 자동 검증하여 손상 시 .next 삭제.
|
||||||
|
*/
|
||||||
|
import { readFileSync, rmSync, existsSync, readdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
const NEXT_DIR = '.next';
|
||||||
|
|
||||||
|
if (!existsSync(NEXT_DIR)) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonFiles = [];
|
||||||
|
try {
|
||||||
|
// .next/ 루트의 JSON 파일들
|
||||||
|
for (const f of readdirSync(NEXT_DIR)) {
|
||||||
|
if (f.endsWith('.json')) jsonFiles.push(join(NEXT_DIR, f));
|
||||||
|
}
|
||||||
|
// .next/server/ 의 JSON 파일들
|
||||||
|
const serverDir = join(NEXT_DIR, 'server');
|
||||||
|
if (existsSync(serverDir)) {
|
||||||
|
for (const f of readdirSync(serverDir)) {
|
||||||
|
if (f.endsWith('.json')) jsonFiles.push(join(serverDir, f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 디렉토리 읽기 실패 시 무시
|
||||||
|
}
|
||||||
|
|
||||||
|
let corrupted = false;
|
||||||
|
for (const file of jsonFiles) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(file, 'utf8');
|
||||||
|
JSON.parse(content);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`⚠️ 손상된 캐시 발견: ${file}`);
|
||||||
|
console.warn(` ${e.message}`);
|
||||||
|
corrupted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (corrupted) {
|
||||||
|
console.warn('🗑️ .next 캐시를 삭제하고 재빌드합니다...');
|
||||||
|
rmSync(NEXT_DIR, { recursive: true, force: true });
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ export default function BadDebtCollectionPage() {
|
|||||||
return (
|
return (
|
||||||
<BadDebtCollection
|
<BadDebtCollection
|
||||||
initialData={data}
|
initialData={data}
|
||||||
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; recovered_amount: number; bad_debt_amount: number; } | null}
|
initialSummary={summary as { total_amount: number; collecting_amount: number; legal_action_amount: number; collection_end_amount: number; } | null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default function VendorsPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mode !== 'new') {
|
if (mode !== 'new') {
|
||||||
getClients({ size: 100 })
|
getClients({ size: 1000 })
|
||||||
.then(result => {
|
.then(result => {
|
||||||
setData(result.data);
|
setData(result.data);
|
||||||
setTotal(result.total);
|
setTotal(result.total);
|
||||||
|
|||||||
5
src/app/[locale]/(protected)/approval/completed/page.tsx
Normal file
5
src/app/[locale]/(protected)/approval/completed/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { CompletedBox } from '@/components/approval/CompletedBox';
|
||||||
|
|
||||||
|
export default function ApprovalCompletedPage() {
|
||||||
|
return <CompletedBox />;
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ function BoardListContent({ boardCode }: { boardCode: string }) {
|
|||||||
|
|
||||||
// 게시글 목록
|
// 게시글 목록
|
||||||
const [posts, setPosts] = useState<BoardPost[]>([]);
|
const [posts, setPosts] = useState<BoardPost[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 필터 및 검색
|
// 필터 및 검색
|
||||||
@@ -239,11 +239,11 @@ function BoardListContent({ boardCode }: { boardCode: string }) {
|
|||||||
// 테이블 컬럼
|
// 테이블 컬럼
|
||||||
const tableColumns: TableColumn[] = useMemo(() => [
|
const tableColumns: TableColumn[] = useMemo(() => [
|
||||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||||
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
|
{ key: 'title', label: '제목', className: 'min-w-[200px]', copyable: true },
|
||||||
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
{ key: 'author', label: '작성자', className: 'w-[120px]', copyable: true },
|
||||||
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' },
|
{ key: 'views', label: '조회수', className: 'w-[80px] text-center', copyable: true },
|
||||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
|
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
// 테이블 행 렌더링
|
// 테이블 행 렌더링
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import {
|
|||||||
deleteDynamicBoardPost,
|
deleteDynamicBoardPost,
|
||||||
} from '@/components/board/DynamicBoard/actions';
|
} from '@/components/board/DynamicBoard/actions';
|
||||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||||
import { transformApiToComment, type CommentApiData } from '@/components/customer-center/shared/types';
|
import { transformApiToComment } from '@/components/customer-center/shared/types';
|
||||||
import type { PostApiData } from '@/components/customer-center/shared/types';
|
import type { PostApiData } from '@/components/customer-center/shared/types';
|
||||||
import { sanitizeHTML } from '@/lib/sanitize';
|
import { sanitizeHTML } from '@/lib/sanitize';
|
||||||
|
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) {
|
|||||||
|
|
||||||
// 게시글 목록
|
// 게시글 목록
|
||||||
const [posts, setPosts] = useState<BoardPost[]>([]);
|
const [posts, setPosts] = useState<BoardPost[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 필터 및 검색
|
// 필터 및 검색
|
||||||
@@ -246,11 +246,11 @@ function DynamicBoardListContent({ boardCode }: { boardCode: string }) {
|
|||||||
// 테이블 컬럼
|
// 테이블 컬럼
|
||||||
const tableColumns: TableColumn[] = useMemo(() => [
|
const tableColumns: TableColumn[] = useMemo(() => [
|
||||||
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
{ key: 'no', label: 'No.', className: 'w-[60px] text-center' },
|
||||||
{ key: 'title', label: '제목', className: 'min-w-[200px]' },
|
{ key: 'title', label: '제목', className: 'min-w-[200px]', copyable: true },
|
||||||
{ key: 'author', label: '작성자', className: 'w-[120px]' },
|
{ key: 'author', label: '작성자', className: 'w-[120px]', copyable: true },
|
||||||
{ key: 'views', label: '조회수', className: 'w-[80px] text-center' },
|
{ key: 'views', label: '조회수', className: 'w-[80px] text-center', copyable: true },
|
||||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||||
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center' },
|
{ key: 'createdAt', label: '등록일', className: 'w-[120px] text-center', copyable: true },
|
||||||
], []);
|
], []);
|
||||||
|
|
||||||
// 테이블 행 렌더링
|
// 테이블 행 렌더링
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { CategoryManagement } from '@/components/business/construction/category-management';
|
import { CategoryManagement } from '@/components/business/construction/category-management';
|
||||||
|
|
||||||
export default function CategoriesPage() {
|
export default function CategoriesPage() {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ interface OrderDetailPageProps {
|
|||||||
|
|
||||||
export default function OrderDetailPage({ params }: OrderDetailPageProps) {
|
export default function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const router = useRouter();
|
const _router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||||
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(undefined);
|
const [data, setData] = useState<Awaited<ReturnType<typeof getOrderDetailFull>>['data']>(undefined);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface ContractDetailPageProps {
|
|||||||
|
|
||||||
export default function ContractDetailPage({ params }: ContractDetailPageProps) {
|
export default function ContractDetailPage({ params }: ContractDetailPageProps) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const router = useRouter();
|
const _router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||||
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
const [data, setData] = useState<Awaited<ReturnType<typeof getContractDetail>>['data']>(undefined);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ interface HandoverReportDetailPageProps {
|
|||||||
|
|
||||||
export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
const router = useRouter();
|
const _router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
|
||||||
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
const [data, setData] = useState<Awaited<ReturnType<typeof getHandoverReportDetail>>['data']>(undefined);
|
||||||
|
|||||||
1322
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
1322
src/app/[locale]/(protected)/dev/bill-prototype/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -96,6 +96,14 @@ import { DataTable } from '@/components/organisms/DataTable';
|
|||||||
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal';
|
import { SearchableSelectionModal } from '@/components/organisms/SearchableSelectionModal/SearchableSelectionModal';
|
||||||
// UI - 추가
|
// UI - 추가
|
||||||
import { VisuallyHidden } from '@/components/ui/visually-hidden';
|
import { VisuallyHidden } from '@/components/ui/visually-hidden';
|
||||||
|
import { DateRangePicker } from '@/components/ui/date-range-picker';
|
||||||
|
import { DateTimePicker } from '@/components/ui/date-time-picker';
|
||||||
|
// Molecules - 추가
|
||||||
|
import { ColumnSettingsPopover } from '@/components/molecules/ColumnSettingsPopover';
|
||||||
|
import { GenericCRUDDialog } from '@/components/molecules/GenericCRUDDialog';
|
||||||
|
import { ReorderButtons } from '@/components/molecules/ReorderButtons';
|
||||||
|
// Organisms - 추가
|
||||||
|
import { LineItemsTable } from '@/components/organisms/LineItemsTable/LineItemsTable';
|
||||||
// Lucide icons for demos
|
// Lucide icons for demos
|
||||||
import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react';
|
import { Bell, Package, FileText, Users, TrendingUp, Settings, Inbox } from 'lucide-react';
|
||||||
|
|
||||||
@@ -339,6 +347,89 @@ function SearchableSelectionDemo() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 추가 Demo Wrappers ──
|
||||||
|
|
||||||
|
function DateRangePickerDemo() {
|
||||||
|
const [start, setStart] = useState<string | undefined>();
|
||||||
|
const [end, setEnd] = useState<string | undefined>();
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm">
|
||||||
|
<DateRangePicker startDate={start} endDate={end} onStartDateChange={setStart} onEndDateChange={setEnd} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DateTimePickerDemo() {
|
||||||
|
const [v, setV] = useState<string | undefined>();
|
||||||
|
return (
|
||||||
|
<div className="max-w-sm">
|
||||||
|
<DateTimePicker value={v} onChange={setV} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColumnSettingsPopoverDemo() {
|
||||||
|
const [cols, setCols] = useState([
|
||||||
|
{ key: 'name', label: '품목명', visible: true, locked: true },
|
||||||
|
{ key: 'spec', label: '규격', visible: true, locked: false },
|
||||||
|
{ key: 'qty', label: '수량', visible: true, locked: false },
|
||||||
|
{ key: 'price', label: '단가', visible: false, locked: false },
|
||||||
|
{ key: 'note', label: '비고', visible: false, locked: false },
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<ColumnSettingsPopover
|
||||||
|
columns={cols}
|
||||||
|
onToggle={(key) => setCols((prev) => prev.map((c) => (c.key === key && !c.locked ? { ...c, visible: !c.visible } : c)))}
|
||||||
|
onReset={() => setCols((prev) => prev.map((c) => ({ ...c, visible: true })))}
|
||||||
|
hasHiddenColumns={cols.some((c) => !c.visible)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function GenericCRUDDialogDemo() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setOpen(true)}>CRUD 다이얼로그 열기</Button>
|
||||||
|
<GenericCRUDDialog
|
||||||
|
isOpen={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
mode="add"
|
||||||
|
entityName="직급"
|
||||||
|
fields={[
|
||||||
|
{ key: 'name', label: '직급명', type: 'text', placeholder: '직급명 입력' },
|
||||||
|
{ key: 'status', label: '상태', type: 'select', options: [{ value: 'active', label: '활성' }, { value: 'inactive', label: '비활성' }], defaultValue: 'active' },
|
||||||
|
]}
|
||||||
|
onSubmit={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineItemsTableDemo() {
|
||||||
|
const [items, setItems] = useState([
|
||||||
|
{ id: '1', itemName: '볼트 M10x30', quantity: 100, unitPrice: 500, supplyAmount: 50000, vat: 5000, note: '' },
|
||||||
|
{ id: '2', itemName: '너트 M10', quantity: 200, unitPrice: 300, supplyAmount: 60000, vat: 6000, note: '' },
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl overflow-x-auto">
|
||||||
|
<LineItemsTable
|
||||||
|
items={items}
|
||||||
|
getItemName={(i) => i.itemName}
|
||||||
|
getQuantity={(i) => i.quantity}
|
||||||
|
getUnitPrice={(i) => i.unitPrice}
|
||||||
|
getSupplyAmount={(i) => i.supplyAmount}
|
||||||
|
getVat={(i) => i.vat}
|
||||||
|
getNote={(i) => i.note}
|
||||||
|
onItemChange={(idx, field, value) => setItems((prev) => prev.map((item, i) => (i === idx ? { ...item, [field]: value } : item)))}
|
||||||
|
onAddItem={() => setItems((prev) => [...prev, { id: String(prev.length + 1), itemName: '', quantity: 1, unitPrice: 0, supplyAmount: 0, vat: 0, note: '' }])}
|
||||||
|
onRemoveItem={(idx) => setItems((prev) => prev.filter((_, i) => i !== idx))}
|
||||||
|
totals={{ supplyAmount: items.reduce((s, i) => s + i.supplyAmount, 0), vat: items.reduce((s, i) => s + i.vat, 0), total: items.reduce((s, i) => s + i.supplyAmount + i.vat, 0) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Preview Registry ──
|
// ── Preview Registry ──
|
||||||
|
|
||||||
type PreviewEntry = {
|
type PreviewEntry = {
|
||||||
@@ -937,6 +1028,14 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'date-range-picker.tsx': [
|
||||||
|
{ label: 'DateRangePicker', render: () => <DateRangePickerDemo /> },
|
||||||
|
],
|
||||||
|
|
||||||
|
'date-time-picker.tsx': [
|
||||||
|
{ label: 'DateTimePicker', render: () => <DateTimePickerDemo /> },
|
||||||
|
],
|
||||||
|
|
||||||
// ─── Atoms ───
|
// ─── Atoms ───
|
||||||
'BadgeSm.tsx': [
|
'BadgeSm.tsx': [
|
||||||
{
|
{
|
||||||
@@ -1184,6 +1283,36 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
|||||||
{ label: 'Filter', render: () => <MobileFilterDemo /> },
|
{ label: 'Filter', render: () => <MobileFilterDemo /> },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'ColumnSettingsPopover.tsx': [
|
||||||
|
{ label: 'Popover', render: () => <ColumnSettingsPopoverDemo /> },
|
||||||
|
],
|
||||||
|
|
||||||
|
'GenericCRUDDialog.tsx': [
|
||||||
|
{ label: 'CRUD Dialog', render: () => <GenericCRUDDialogDemo /> },
|
||||||
|
],
|
||||||
|
|
||||||
|
'ReorderButtons.tsx': [
|
||||||
|
{
|
||||||
|
label: 'Sizes',
|
||||||
|
render: () => (
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">sm:</span>
|
||||||
|
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="sm" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">xs:</span>
|
||||||
|
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={false} isLast={false} size="xs" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground">disabled:</span>
|
||||||
|
<ReorderButtons onMoveUp={() => {}} onMoveDown={() => {}} isFirst={true} isLast={true} size="sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
// ─── Organisms ───
|
// ─── Organisms ───
|
||||||
'EmptyState.tsx': [
|
'EmptyState.tsx': [
|
||||||
{
|
{
|
||||||
@@ -1440,4 +1569,8 @@ export const UI_PREVIEWS: Record<string, PreviewEntry[]> = {
|
|||||||
'SearchableSelectionModal.tsx': [
|
'SearchableSelectionModal.tsx': [
|
||||||
{ label: 'Modal', render: () => <SearchableSelectionDemo /> },
|
{ label: 'Modal', render: () => <SearchableSelectionDemo /> },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'LineItemsTable.tsx': [
|
||||||
|
{ label: 'Line Items', render: () => <LineItemsTableDemo /> },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { useState, useCallback } from 'react';
|
|||||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||||
import { EditableTable, EditableColumn } from '@/components/common/EditableTable';
|
import { EditableTable, EditableColumn } from '@/components/common/EditableTable';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export default function AttendancePage() {
|
|||||||
|
|
||||||
setSiteLocation(finalLocation);
|
setSiteLocation(finalLocation);
|
||||||
} else {
|
} else {
|
||||||
|
// no fallback location needed
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AttendancePage] loadSettings error:', error);
|
console.error('[AttendancePage] loadSettings error:', error);
|
||||||
|
|||||||
@@ -11,23 +11,17 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useSearchParams, useRouter } from 'next/navigation';
|
import { useSearchParams, useRouter } from 'next/navigation';
|
||||||
import { useState, useEffect, useMemo, Suspense } from 'react';
|
import { useState, useMemo, Suspense } from 'react';
|
||||||
import { FileText, ArrowLeft, Calendar, User, Clock, MapPin, FileCheck } from 'lucide-react';
|
import { FileText, ArrowLeft, Calendar, Clock, MapPin, FileCheck } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { FormSectionSkeleton } from '@/components/ui/skeleton';
|
import { FormSectionSkeleton } from '@/components/ui/skeleton';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { ko } from 'date-fns/locale';
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
// 문서 유형 라벨
|
// 문서 유형 라벨
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { CSVUploadPage } from '@/components/hr/EmployeeManagement/CSVUploadPage'
|
|||||||
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
import type { Employee } from '@/components/hr/EmployeeManagement/types';
|
||||||
|
|
||||||
export default function EmployeeCSVUploadPage() {
|
export default function EmployeeCSVUploadPage() {
|
||||||
const handleUpload = (employees: Employee[]) => {
|
const handleUpload = (_employees: Employee[]) => {
|
||||||
// TODO: API 연동
|
// TODO: API 연동
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ function EmployeeManagementContent() {
|
|||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (_error) {
|
||||||
toast.error('서버 오류가 발생했습니다.');
|
toast.error('서버 오류가 발생했습니다.');
|
||||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일상점검표 - 그리드 매트릭스
|
||||||
|
* URL: /quality/equipment-inspections
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EquipmentInspectionGrid } from '@/components/quality/EquipmentInspection';
|
||||||
|
|
||||||
|
export default function EquipmentInspectionsPage() {
|
||||||
|
return <EquipmentInspectionGrid />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레거시 리다이렉트: /quality/equipment-repairs/new → ?mode=new
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function RepairNewRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace('/quality/equipment-repairs?mode=new');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수리이력 목록/등록
|
||||||
|
* URL: /quality/equipment-repairs
|
||||||
|
* URL: /quality/equipment-repairs?mode=new (등록)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { RepairList } from '@/components/quality/EquipmentRepair';
|
||||||
|
import { RepairForm } from '@/components/quality/EquipmentRepair/RepairForm';
|
||||||
|
|
||||||
|
export default function EquipmentRepairsPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
|
if (mode === 'new') {
|
||||||
|
return <RepairForm />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <RepairList />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설비 현황 대시보드
|
||||||
|
* URL: /quality/equipment-status
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EquipmentStatusDashboard } from '@/components/quality/EquipmentStatus';
|
||||||
|
|
||||||
|
export default function EquipmentStatusPage() {
|
||||||
|
return <EquipmentStatusDashboard />;
|
||||||
|
}
|
||||||
19
src/app/[locale]/(protected)/quality/equipment/[id]/page.tsx
Normal file
19
src/app/[locale]/(protected)/quality/equipment/[id]/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설비 상세/수정 페이지
|
||||||
|
* URL: /quality/equipment/[id]
|
||||||
|
* 수정 모드: /quality/equipment/[id]?mode=edit
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { use } from 'react';
|
||||||
|
import { EquipmentDetail } from '@/components/quality/EquipmentManagement/EquipmentDetail';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EquipmentDetailPage({ params }: Props) {
|
||||||
|
const { id } = use(params);
|
||||||
|
return <EquipmentDetail id={id} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설비 엑셀 Import 페이지
|
||||||
|
* URL: /quality/equipment/import
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EquipmentImport } from '@/components/quality/EquipmentManagement/EquipmentImport';
|
||||||
|
|
||||||
|
export default function EquipmentImportPage() {
|
||||||
|
return <EquipmentImport />;
|
||||||
|
}
|
||||||
22
src/app/[locale]/(protected)/quality/equipment/new/page.tsx
Normal file
22
src/app/[locale]/(protected)/quality/equipment/new/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레거시 리다이렉트: /quality/equipment/new → ?mode=new
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
|
export default function EquipmentNewRedirect() {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.replace('/quality/equipment?mode=new');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-muted-foreground">리다이렉트 중...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/[locale]/(protected)/quality/equipment/page.tsx
Normal file
22
src/app/[locale]/(protected)/quality/equipment/page.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설비 등록대장 - 목록/등록
|
||||||
|
* URL: /quality/equipment
|
||||||
|
* URL: /quality/equipment?mode=new (등록)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
|
import { EquipmentManagement } from '@/components/quality/EquipmentManagement';
|
||||||
|
import { EquipmentForm } from '@/components/quality/EquipmentManagement/EquipmentForm';
|
||||||
|
|
||||||
|
export default function EquipmentPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const mode = searchParams.get('mode');
|
||||||
|
|
||||||
|
if (mode === 'new') {
|
||||||
|
return <EquipmentForm />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <EquipmentManagement />;
|
||||||
|
}
|
||||||
340
src/app/[locale]/(protected)/quality/qms/actions.ts
Normal file
340
src/app/[locale]/(protected)/quality/qms/actions.ts
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
'use server';
|
||||||
|
|
||||||
|
import { executeServerAction } from '@/lib/api/execute-server-action';
|
||||||
|
import { buildApiUrl } from '@/lib/api/query-params';
|
||||||
|
|
||||||
|
// ===== API 원본 타입 (snake_case) =====
|
||||||
|
// ⚠️ 'use server' 파일에서 타입 re-export 금지 (Turbopack 제한)
|
||||||
|
|
||||||
|
interface QualityReportApi {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
site_name: string;
|
||||||
|
item: string;
|
||||||
|
route_count: number;
|
||||||
|
total_routes: number;
|
||||||
|
quarter: string;
|
||||||
|
year: number;
|
||||||
|
quarter_num: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RouteItemApi {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
date: string;
|
||||||
|
client: string;
|
||||||
|
site: string;
|
||||||
|
location_count: number;
|
||||||
|
sub_items: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
location: string;
|
||||||
|
is_completed: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentApi {
|
||||||
|
id: number;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
date?: string;
|
||||||
|
count: number;
|
||||||
|
file_id?: number;
|
||||||
|
file_name?: string;
|
||||||
|
file_size?: number;
|
||||||
|
items?: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
code?: string;
|
||||||
|
sub_type?: string;
|
||||||
|
work_order_id?: number;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Transform 함수 (snake_case → camelCase) =====
|
||||||
|
|
||||||
|
function transformReportApi(api: QualityReportApi) {
|
||||||
|
return {
|
||||||
|
id: String(api.id),
|
||||||
|
code: api.code,
|
||||||
|
siteName: api.site_name,
|
||||||
|
item: api.item,
|
||||||
|
routeCount: api.route_count,
|
||||||
|
totalRoutes: api.total_routes,
|
||||||
|
quarter: api.quarter,
|
||||||
|
year: api.year,
|
||||||
|
quarterNum: api.quarter_num,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformRouteApi(api: RouteItemApi) {
|
||||||
|
return {
|
||||||
|
id: String(api.id),
|
||||||
|
code: api.code,
|
||||||
|
date: api.date,
|
||||||
|
client: api.client,
|
||||||
|
site: api.site,
|
||||||
|
locationCount: api.location_count,
|
||||||
|
subItems: api.sub_items.map((s) => ({
|
||||||
|
id: String(s.id),
|
||||||
|
name: s.name,
|
||||||
|
location: s.location,
|
||||||
|
isCompleted: s.is_completed,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformDocumentApi(api: DocumentApi) {
|
||||||
|
return {
|
||||||
|
id: String(api.id),
|
||||||
|
type: api.type as 'import' | 'order' | 'log' | 'report' | 'confirmation' | 'shipping' | 'product' | 'quality',
|
||||||
|
title: api.title,
|
||||||
|
date: api.date,
|
||||||
|
count: api.count,
|
||||||
|
fileId: api.file_id,
|
||||||
|
fileName: api.file_name,
|
||||||
|
fileSize: api.file_size,
|
||||||
|
items: api.items?.map((i) => ({
|
||||||
|
id: String(i.id),
|
||||||
|
title: i.title,
|
||||||
|
date: i.date,
|
||||||
|
code: i.code,
|
||||||
|
subType: i.sub_type as 'screen' | 'bending' | 'slat' | 'jointbar' | undefined,
|
||||||
|
workOrderId: i.work_order_id,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 2일차: 로트 추적 심사 =====
|
||||||
|
|
||||||
|
export async function getQualityReports(params: {
|
||||||
|
year: number;
|
||||||
|
quarter?: number;
|
||||||
|
q?: string;
|
||||||
|
}) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/qms/lot-audit/reports', {
|
||||||
|
year: params.year,
|
||||||
|
quarter: params.quarter,
|
||||||
|
q: params.q,
|
||||||
|
}),
|
||||||
|
transform: (data: { items: QualityReportApi[] }) =>
|
||||||
|
data.items.map(transformReportApi),
|
||||||
|
errorMessage: '품질관리서 목록 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReportRoutes(reportId: string) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/qms/lot-audit/reports/${reportId}`),
|
||||||
|
transform: (data: RouteItemApi[]) => data.map(transformRouteApi),
|
||||||
|
errorMessage: '수주/개소 목록 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRouteDocuments(routeId: string) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/qms/lot-audit/routes/${routeId}/documents`),
|
||||||
|
transform: (data: DocumentApi[]) => data.map(transformDocumentApi),
|
||||||
|
errorMessage: '서류 목록 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocumentDetail(type: string, id: string) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/qms/lot-audit/documents/${type}/${id}`),
|
||||||
|
errorMessage: '서류 상세 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmUnitInspection(unitId: string, confirmed: boolean) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/qms/lot-audit/units/${unitId}/confirm`),
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { confirmed },
|
||||||
|
transform: (data: { id: number; name: string; location: string; is_completed: boolean }) => ({
|
||||||
|
id: String(data.id),
|
||||||
|
name: data.name,
|
||||||
|
location: data.location,
|
||||||
|
isCompleted: data.is_completed,
|
||||||
|
}),
|
||||||
|
errorMessage: '확인 상태 변경에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 1일차: 점검표 항목 토글 =====
|
||||||
|
|
||||||
|
export async function toggleTemplateItem(templateId: number, subItemId: string) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/quality/checklist-templates/${templateId}/items/${subItemId}/toggle`),
|
||||||
|
method: 'PATCH',
|
||||||
|
transform: (data: { id: string; name: string; is_completed: boolean; completed_at?: string }) => ({
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
isCompleted: data.is_completed,
|
||||||
|
}),
|
||||||
|
errorMessage: '항목 상태 변경에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 점검표 템플릿 관리 (설정 모달) =====
|
||||||
|
|
||||||
|
interface ChecklistTemplateApi {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
categories: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subItems: { id: string; name: string; is_completed?: boolean }[];
|
||||||
|
}[];
|
||||||
|
options: Record<string, unknown> | null;
|
||||||
|
file_counts: Record<string, number>;
|
||||||
|
updated_at: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TemplateDocumentApi {
|
||||||
|
id: number;
|
||||||
|
field_key: string;
|
||||||
|
display_name: string;
|
||||||
|
file_size: number;
|
||||||
|
mime_type: string;
|
||||||
|
uploaded_by: string | null;
|
||||||
|
created_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChecklistTemplate(type: string = 'day1_audit') {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/quality/checklist-templates', { type }),
|
||||||
|
transform: (data: ChecklistTemplateApi) => ({
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
type: data.type,
|
||||||
|
categories: data.categories.map((cat) => ({
|
||||||
|
id: cat.id,
|
||||||
|
title: cat.title,
|
||||||
|
subItems: cat.subItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
isCompleted: item.is_completed ?? false,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
options: data.options,
|
||||||
|
fileCounts: data.file_counts,
|
||||||
|
updatedAt: data.updated_at,
|
||||||
|
updatedBy: data.updated_by,
|
||||||
|
}),
|
||||||
|
errorMessage: '점검표 템플릿 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveChecklistTemplate(
|
||||||
|
id: number,
|
||||||
|
data: { name?: string; categories: { id: string; title: string; subItems: { id: string; name: string }[] }[]; options?: Record<string, unknown> },
|
||||||
|
) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/quality/checklist-templates/${id}`),
|
||||||
|
method: 'PUT',
|
||||||
|
body: data,
|
||||||
|
transform: (result: ChecklistTemplateApi) => ({
|
||||||
|
id: result.id,
|
||||||
|
name: result.name,
|
||||||
|
type: result.type,
|
||||||
|
categories: result.categories.map((cat) => ({
|
||||||
|
id: cat.id,
|
||||||
|
title: cat.title,
|
||||||
|
subItems: cat.subItems.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
isCompleted: item.is_completed ?? false,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
options: result.options,
|
||||||
|
fileCounts: result.file_counts,
|
||||||
|
updatedAt: result.updated_at,
|
||||||
|
updatedBy: result.updated_by,
|
||||||
|
}),
|
||||||
|
errorMessage: '점검표 템플릿 저장에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateDocuments(templateId: number, subItemId?: string) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/quality/qms-documents', {
|
||||||
|
template_id: templateId,
|
||||||
|
sub_item_id: subItemId,
|
||||||
|
}),
|
||||||
|
transform: (data: TemplateDocumentApi[]) =>
|
||||||
|
data.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
fieldKey: d.field_key,
|
||||||
|
displayName: d.display_name,
|
||||||
|
fileSize: d.file_size,
|
||||||
|
mimeType: d.mime_type,
|
||||||
|
uploadedBy: d.uploaded_by,
|
||||||
|
createdAt: d.created_at,
|
||||||
|
})),
|
||||||
|
errorMessage: '템플릿 문서 조회에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadTemplateDocument(templateId: number, subItemId: string, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('template_id', String(templateId));
|
||||||
|
formData.append('sub_item_id', subItemId);
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl('/api/v1/quality/qms-documents'),
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
transform: (d: TemplateDocumentApi) => ({
|
||||||
|
id: d.id,
|
||||||
|
fieldKey: d.field_key,
|
||||||
|
displayName: d.display_name,
|
||||||
|
fileSize: d.file_size,
|
||||||
|
mimeType: d.mime_type,
|
||||||
|
createdAt: d.created_at,
|
||||||
|
}),
|
||||||
|
errorMessage: '파일 업로드에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplateDocument(fileId: number, replace: boolean = false) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/quality/qms-documents/${fileId}`, {
|
||||||
|
replace: replace ? 'true' : undefined,
|
||||||
|
}),
|
||||||
|
method: 'DELETE',
|
||||||
|
errorMessage: '파일 삭제에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 품질관리서 파일 업로드/삭제 =====
|
||||||
|
|
||||||
|
export async function uploadQualityDocumentFile(qualityDocumentId: string, file: File) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/quality/documents/${qualityDocumentId}/upload-file`),
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
transform: (data: { id: number; display_name: string; file_size: number }) => ({
|
||||||
|
fileId: data.id,
|
||||||
|
fileName: data.display_name,
|
||||||
|
fileSize: data.file_size,
|
||||||
|
}),
|
||||||
|
errorMessage: '품질관리서 파일 업로드에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteQualityDocumentFile(qualityDocumentId: string) {
|
||||||
|
return executeServerAction({
|
||||||
|
url: buildApiUrl(`/api/v1/quality/documents/${qualityDocumentId}/file`),
|
||||||
|
method: 'DELETE',
|
||||||
|
errorMessage: '품질관리서 파일 삭제에 실패했습니다.',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Settings, X, Eye, EyeOff } from 'lucide-react';
|
import { Settings, X, Eye, EyeOff, ListChecks } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChecklistTemplateEditor } from './ChecklistTemplateEditor';
|
||||||
|
import type { ChecklistCategory } from '../types';
|
||||||
|
|
||||||
export interface AuditDisplaySettings {
|
export interface AuditDisplaySettings {
|
||||||
showProgressBar: boolean;
|
showProgressBar: boolean;
|
||||||
@@ -13,19 +15,46 @@ export interface AuditDisplaySettings {
|
|||||||
expandAllCategories: boolean;
|
expandAllCategories: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 점검표 관리 props
|
||||||
|
export interface ChecklistManagementProps {
|
||||||
|
categories: ChecklistCategory[];
|
||||||
|
hasChanges: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
onAddCategory: () => void;
|
||||||
|
onUpdateCategoryTitle: (categoryId: string, title: string) => void;
|
||||||
|
onDeleteCategory: (categoryId: string) => void;
|
||||||
|
onMoveCategoryUp: (index: number) => void;
|
||||||
|
onMoveCategoryDown: (index: number) => void;
|
||||||
|
onAddSubItem: (categoryId: string) => void;
|
||||||
|
onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void;
|
||||||
|
onDeleteSubItem: (categoryId: string, subItemId: string) => void;
|
||||||
|
onMoveSubItemUp: (categoryId: string, index: number) => void;
|
||||||
|
onMoveSubItemDown: (categoryId: string, index: number) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
interface AuditSettingsPanelProps {
|
interface AuditSettingsPanelProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
settings: AuditDisplaySettings;
|
settings: AuditDisplaySettings;
|
||||||
onSettingsChange: (settings: AuditDisplaySettings) => void;
|
onSettingsChange: (settings: AuditDisplaySettings) => void;
|
||||||
|
checklistManagement?: ChecklistManagementProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TabType = 'display' | 'checklist';
|
||||||
|
|
||||||
export function AuditSettingsPanel({
|
export function AuditSettingsPanel({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
settings,
|
settings,
|
||||||
onSettingsChange,
|
onSettingsChange,
|
||||||
|
checklistManagement,
|
||||||
}: AuditSettingsPanelProps) {
|
}: AuditSettingsPanelProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('display');
|
||||||
|
|
||||||
const handleToggle = (key: keyof AuditDisplaySettings) => {
|
const handleToggle = (key: keyof AuditDisplaySettings) => {
|
||||||
onSettingsChange({
|
onSettingsChange({
|
||||||
...settings,
|
...settings,
|
||||||
@@ -49,7 +78,7 @@ export function AuditSettingsPanel({
|
|||||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Settings className="h-5 w-5 text-gray-600" />
|
<Settings className="h-5 w-5 text-gray-600" />
|
||||||
<h3 className="font-semibold text-gray-900">화면 설정</h3>
|
<h3 className="font-semibold text-gray-900">설정</h3>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -60,103 +89,183 @@ export function AuditSettingsPanel({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 설정 항목 */}
|
{/* 탭 */}
|
||||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
<div className="flex border-b border-gray-200">
|
||||||
{/* 레이아웃 섹션 */}
|
<button
|
||||||
<div>
|
type="button"
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">레이아웃</h4>
|
onClick={() => setActiveTab('display')}
|
||||||
<div className="space-y-3">
|
className={cn(
|
||||||
<SettingRow
|
'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
|
||||||
label="진행률 표시"
|
activeTab === 'display'
|
||||||
description="상단 전체 심사 진행률 바를 표시합니다"
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
checked={settings.showProgressBar}
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
onChange={() => handleToggle('showProgressBar')}
|
)}
|
||||||
/>
|
>
|
||||||
<SettingRow
|
<Eye className="h-3.5 w-3.5" />
|
||||||
label="문서 뷰어"
|
화면 설정
|
||||||
description="우측 문서 미리보기 패널을 표시합니다"
|
</button>
|
||||||
checked={settings.showDocumentViewer}
|
<button
|
||||||
onChange={() => handleToggle('showDocumentViewer')}
|
type="button"
|
||||||
/>
|
onClick={() => setActiveTab('checklist')}
|
||||||
<SettingRow
|
className={cn(
|
||||||
label="기준 문서화 섹션"
|
'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors',
|
||||||
description="중앙 기준 문서 목록 패널을 표시합니다"
|
activeTab === 'checklist'
|
||||||
checked={settings.showDocumentSection}
|
? 'text-blue-600 border-b-2 border-blue-600'
|
||||||
onChange={() => handleToggle('showDocumentSection')}
|
: 'text-gray-500 hover:text-gray-700'
|
||||||
/>
|
)}
|
||||||
</div>
|
>
|
||||||
</div>
|
<ListChecks className="h-3.5 w-3.5" />
|
||||||
|
점검표 관리
|
||||||
{/* 구분선 */}
|
</button>
|
||||||
<div className="border-t border-gray-200" />
|
|
||||||
|
|
||||||
{/* 점검표 섹션 */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">점검표 옵션</h4>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<SettingRow
|
|
||||||
label="완료된 항목 표시"
|
|
||||||
description="완료된 점검 항목을 목록에 표시합니다"
|
|
||||||
checked={settings.showCompletedItems}
|
|
||||||
onChange={() => handleToggle('showCompletedItems')}
|
|
||||||
/>
|
|
||||||
<SettingRow
|
|
||||||
label="모든 카테고리 펼치기"
|
|
||||||
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
|
|
||||||
checked={settings.expandAllCategories}
|
|
||||||
onChange={() => handleToggle('expandAllCategories')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 구분선 */}
|
|
||||||
<div className="border-t border-gray-200" />
|
|
||||||
|
|
||||||
{/* 빠른 설정 */}
|
|
||||||
<div>
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-3">빠른 설정</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSettingsChange({
|
|
||||||
showProgressBar: true,
|
|
||||||
showDocumentViewer: true,
|
|
||||||
showDocumentSection: true,
|
|
||||||
showCompletedItems: true,
|
|
||||||
expandAllCategories: true,
|
|
||||||
})}
|
|
||||||
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
|
|
||||||
>
|
|
||||||
모두 표시
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSettingsChange({
|
|
||||||
showProgressBar: false,
|
|
||||||
showDocumentViewer: false,
|
|
||||||
showDocumentSection: true,
|
|
||||||
showCompletedItems: true,
|
|
||||||
expandAllCategories: false,
|
|
||||||
})}
|
|
||||||
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
|
||||||
>
|
|
||||||
간소화
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 하단 안내 */}
|
{/* 탭 컨텐츠 */}
|
||||||
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<p className="text-xs text-gray-500">
|
{activeTab === 'display' ? (
|
||||||
설정은 자동으로 저장됩니다
|
<DisplaySettingsContent
|
||||||
</p>
|
settings={settings}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onSettingsChange={onSettingsChange}
|
||||||
|
/>
|
||||||
|
) : checklistManagement ? (
|
||||||
|
<ChecklistTemplateEditor
|
||||||
|
categories={checklistManagement.categories}
|
||||||
|
hasChanges={checklistManagement.hasChanges}
|
||||||
|
saving={checklistManagement.saving}
|
||||||
|
loading={checklistManagement.loading}
|
||||||
|
error={checklistManagement.error}
|
||||||
|
onAddCategory={checklistManagement.onAddCategory}
|
||||||
|
onUpdateCategoryTitle={checklistManagement.onUpdateCategoryTitle}
|
||||||
|
onDeleteCategory={checklistManagement.onDeleteCategory}
|
||||||
|
onMoveCategoryUp={checklistManagement.onMoveCategoryUp}
|
||||||
|
onMoveCategoryDown={checklistManagement.onMoveCategoryDown}
|
||||||
|
onAddSubItem={checklistManagement.onAddSubItem}
|
||||||
|
onUpdateSubItemName={checklistManagement.onUpdateSubItemName}
|
||||||
|
onDeleteSubItem={checklistManagement.onDeleteSubItem}
|
||||||
|
onMoveSubItemUp={checklistManagement.onMoveSubItemUp}
|
||||||
|
onMoveSubItemDown={checklistManagement.onMoveSubItemDown}
|
||||||
|
onSave={checklistManagement.onSave}
|
||||||
|
onReset={checklistManagement.onReset}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||||
|
점검표 관리 데이터를 불러오는 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하단 안내 (화면 설정 탭일 때만) */}
|
||||||
|
{activeTab === 'display' && (
|
||||||
|
<div className="px-4 py-3 border-t border-gray-200 bg-gray-50">
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
설정은 자동으로 저장됩니다
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 화면 설정 탭 컨텐츠 (기존 코드 분리) =====
|
||||||
|
|
||||||
|
interface DisplaySettingsContentProps {
|
||||||
|
settings: AuditDisplaySettings;
|
||||||
|
onToggle: (key: keyof AuditDisplaySettings) => void;
|
||||||
|
onSettingsChange: (settings: AuditDisplaySettings) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DisplaySettingsContent({ settings, onToggle, onSettingsChange }: DisplaySettingsContentProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 레이아웃 섹션 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-3">레이아웃</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SettingRow
|
||||||
|
label="진행률 표시"
|
||||||
|
description="상단 전체 심사 진행률 바를 표시합니다"
|
||||||
|
checked={settings.showProgressBar}
|
||||||
|
onChange={() => onToggle('showProgressBar')}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
label="문서 뷰어"
|
||||||
|
description="우측 문서 미리보기 패널을 표시합니다"
|
||||||
|
checked={settings.showDocumentViewer}
|
||||||
|
onChange={() => onToggle('showDocumentViewer')}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
label="기준 문서화 섹션"
|
||||||
|
description="중앙 기준 문서 목록 패널을 표시합니다"
|
||||||
|
checked={settings.showDocumentSection}
|
||||||
|
onChange={() => onToggle('showDocumentSection')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* 점검표 섹션 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-3">점검표 옵션</h4>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SettingRow
|
||||||
|
label="완료된 항목 표시"
|
||||||
|
description="완료된 점검 항목을 목록에 표시합니다"
|
||||||
|
checked={settings.showCompletedItems}
|
||||||
|
onChange={() => onToggle('showCompletedItems')}
|
||||||
|
/>
|
||||||
|
<SettingRow
|
||||||
|
label="모든 카테고리 펼치기"
|
||||||
|
description="점검표 카테고리를 기본으로 펼쳐서 표시합니다"
|
||||||
|
checked={settings.expandAllCategories}
|
||||||
|
onChange={() => onToggle('expandAllCategories')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="border-t border-gray-200" />
|
||||||
|
|
||||||
|
{/* 빠른 설정 */}
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-700 mb-3">빠른 설정</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSettingsChange({
|
||||||
|
showProgressBar: true,
|
||||||
|
showDocumentViewer: true,
|
||||||
|
showDocumentSection: true,
|
||||||
|
showCompletedItems: true,
|
||||||
|
expandAllCategories: true,
|
||||||
|
})}
|
||||||
|
className="px-3 py-2 text-sm bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
모두 표시
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSettingsChange({
|
||||||
|
showProgressBar: false,
|
||||||
|
showDocumentViewer: false,
|
||||||
|
showDocumentSection: true,
|
||||||
|
showCompletedItems: true,
|
||||||
|
expandAllCategories: false,
|
||||||
|
})}
|
||||||
|
className="px-3 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
간소화
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== 공통 설정 행 =====
|
||||||
|
|
||||||
interface SettingRowProps {
|
interface SettingRowProps {
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
|||||||
@@ -0,0 +1,449 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
ChevronUp,
|
||||||
|
ChevronDown,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
ChevronRight,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { ChecklistCategory, ChecklistSubItem } from '../types';
|
||||||
|
|
||||||
|
interface ChecklistTemplateEditorProps {
|
||||||
|
categories: ChecklistCategory[];
|
||||||
|
hasChanges: boolean;
|
||||||
|
saving: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
// 카테고리
|
||||||
|
onAddCategory: () => void;
|
||||||
|
onUpdateCategoryTitle: (categoryId: string, title: string) => void;
|
||||||
|
onDeleteCategory: (categoryId: string) => void;
|
||||||
|
onMoveCategoryUp: (index: number) => void;
|
||||||
|
onMoveCategoryDown: (index: number) => void;
|
||||||
|
// 하위 항목
|
||||||
|
onAddSubItem: (categoryId: string) => void;
|
||||||
|
onUpdateSubItemName: (categoryId: string, subItemId: string, name: string) => void;
|
||||||
|
onDeleteSubItem: (categoryId: string, subItemId: string) => void;
|
||||||
|
onMoveSubItemUp: (categoryId: string, index: number) => void;
|
||||||
|
onMoveSubItemDown: (categoryId: string, index: number) => void;
|
||||||
|
// 저장/초기화
|
||||||
|
onSave: () => void;
|
||||||
|
onReset: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChecklistTemplateEditor({
|
||||||
|
categories,
|
||||||
|
hasChanges,
|
||||||
|
saving,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onAddCategory,
|
||||||
|
onUpdateCategoryTitle,
|
||||||
|
onDeleteCategory,
|
||||||
|
onMoveCategoryUp,
|
||||||
|
onMoveCategoryDown,
|
||||||
|
onAddSubItem,
|
||||||
|
onUpdateSubItemName,
|
||||||
|
onDeleteSubItem,
|
||||||
|
onMoveSubItemUp,
|
||||||
|
onMoveSubItemDown,
|
||||||
|
onSave,
|
||||||
|
onReset,
|
||||||
|
}: ChecklistTemplateEditorProps) {
|
||||||
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
|
new Set(categories.map(c => c.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleExpand = (categoryId: string) => {
|
||||||
|
setExpandedCategories(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(categoryId)) next.delete(categoryId);
|
||||||
|
else next.add(categoryId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-400 text-sm gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
점검표 로딩 중...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-32 text-red-500 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 카테고리 목록 */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{categories.map((category, catIdx) => (
|
||||||
|
<CategoryEditor
|
||||||
|
key={category.id}
|
||||||
|
category={category}
|
||||||
|
index={catIdx}
|
||||||
|
isFirst={catIdx === 0}
|
||||||
|
isLast={catIdx === categories.length - 1}
|
||||||
|
isExpanded={expandedCategories.has(category.id)}
|
||||||
|
onToggleExpand={() => toggleExpand(category.id)}
|
||||||
|
onUpdateTitle={(title) => onUpdateCategoryTitle(category.id, title)}
|
||||||
|
onDelete={() => onDeleteCategory(category.id)}
|
||||||
|
onMoveUp={() => onMoveCategoryUp(catIdx)}
|
||||||
|
onMoveDown={() => onMoveCategoryDown(catIdx)}
|
||||||
|
onAddSubItem={() => onAddSubItem(category.id)}
|
||||||
|
onUpdateSubItemName={(subItemId, name) => onUpdateSubItemName(category.id, subItemId, name)}
|
||||||
|
onDeleteSubItem={(subItemId) => onDeleteSubItem(category.id, subItemId)}
|
||||||
|
onMoveSubItemUp={(idx) => onMoveSubItemUp(category.id, idx)}
|
||||||
|
onMoveSubItemDown={(idx) => onMoveSubItemDown(category.id, idx)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 추가 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddCategory}
|
||||||
|
className="w-full flex items-center justify-center gap-1.5 py-2 text-xs text-blue-600 bg-blue-50 rounded-lg border border-dashed border-blue-200 hover:bg-blue-100 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
카테고리 추가
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 저장/초기화 */}
|
||||||
|
<div className="flex items-center gap-2 pt-2 border-t border-gray-200">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onReset}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
|
초기화
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSave()}
|
||||||
|
disabled={!hasChanges || saving}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1.5 py-2 text-xs text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" />
|
||||||
|
{saving ? '저장 중...' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasChanges && (
|
||||||
|
<p className="text-[10px] text-amber-600 text-center">
|
||||||
|
저장하지 않은 변경사항이 있습니다
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 카테고리 편집 =====
|
||||||
|
|
||||||
|
interface CategoryEditorProps {
|
||||||
|
category: ChecklistCategory;
|
||||||
|
index: number;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpand: () => void;
|
||||||
|
onUpdateTitle: (title: string) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
onAddSubItem: () => void;
|
||||||
|
onUpdateSubItemName: (subItemId: string, name: string) => void;
|
||||||
|
onDeleteSubItem: (subItemId: string) => void;
|
||||||
|
onMoveSubItemUp: (index: number) => void;
|
||||||
|
onMoveSubItemDown: (index: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CategoryEditor({
|
||||||
|
category,
|
||||||
|
index,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpand,
|
||||||
|
onUpdateTitle,
|
||||||
|
onDelete,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
onAddSubItem,
|
||||||
|
onUpdateSubItemName,
|
||||||
|
onDeleteSubItem,
|
||||||
|
onMoveSubItemUp,
|
||||||
|
onMoveSubItemDown,
|
||||||
|
}: CategoryEditorProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState(category.title);
|
||||||
|
|
||||||
|
const handleSaveTitle = () => {
|
||||||
|
const trimmed = editValue.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
onUpdateTitle(trimmed);
|
||||||
|
} else {
|
||||||
|
setEditValue(category.title);
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') handleSaveTitle();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditValue(category.title);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
{/* 카테고리 헤더 */}
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1.5">
|
||||||
|
{/* 순서 변경 */}
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMoveUp}
|
||||||
|
disabled={isFirst}
|
||||||
|
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMoveDown}
|
||||||
|
disabled={isLast}
|
||||||
|
className="p-0.5 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 펼치기/접기 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleExpand}
|
||||||
|
className="p-0.5 text-gray-500"
|
||||||
|
>
|
||||||
|
<ChevronRight className={cn(
|
||||||
|
'h-3.5 w-3.5 transition-transform',
|
||||||
|
isExpanded && 'rotate-90'
|
||||||
|
)} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 제목 */}
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex-1 flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={handleSaveTitle}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="flex-1 text-xs px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleSaveTitle} className="p-0.5 text-green-600">
|
||||||
|
<Check className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setEditValue(category.title); setEditing(false); }} className="p-0.5 text-gray-400">
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="flex-1 text-xs font-medium text-gray-800 truncate">
|
||||||
|
{index + 1}. {category.title}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 항목 수 */}
|
||||||
|
<span className="text-[10px] text-gray-400 mr-1">
|
||||||
|
{category.subItems.length}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* 편집/삭제 */}
|
||||||
|
{!editing && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditValue(category.title); setEditing(true); }}
|
||||||
|
className="p-1 text-gray-400 hover:text-blue-600 rounded"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-1 text-gray-400 hover:text-red-600 rounded"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 하위 항목 */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="border-t border-gray-200 bg-white">
|
||||||
|
{category.subItems.map((subItem, subIdx) => (
|
||||||
|
<SubItemEditor
|
||||||
|
key={subItem.id}
|
||||||
|
subItem={subItem}
|
||||||
|
index={subIdx}
|
||||||
|
isFirst={subIdx === 0}
|
||||||
|
isLast={subIdx === category.subItems.length - 1}
|
||||||
|
onUpdateName={(name) => onUpdateSubItemName(subItem.id, name)}
|
||||||
|
onDelete={() => onDeleteSubItem(subItem.id)}
|
||||||
|
onMoveUp={() => onMoveSubItemUp(subIdx)}
|
||||||
|
onMoveDown={() => onMoveSubItemDown(subIdx)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 항목 추가 */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onAddSubItem}
|
||||||
|
className="w-full flex items-center justify-center gap-1 py-1.5 text-[10px] text-blue-500 hover:bg-blue-50 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
항목 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 하위 항목 편집 =====
|
||||||
|
|
||||||
|
interface SubItemEditorProps {
|
||||||
|
subItem: ChecklistSubItem;
|
||||||
|
index: number;
|
||||||
|
isFirst: boolean;
|
||||||
|
isLast: boolean;
|
||||||
|
onUpdateName: (name: string) => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
onMoveUp: () => void;
|
||||||
|
onMoveDown: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubItemEditor({
|
||||||
|
subItem,
|
||||||
|
index,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
onUpdateName,
|
||||||
|
onDelete,
|
||||||
|
onMoveUp,
|
||||||
|
onMoveDown,
|
||||||
|
}: SubItemEditorProps) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [editValue, setEditValue] = useState(subItem.name);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
const trimmed = editValue.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
onUpdateName(trimmed);
|
||||||
|
} else {
|
||||||
|
setEditValue(subItem.name);
|
||||||
|
}
|
||||||
|
setEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') handleSave();
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setEditValue(subItem.name);
|
||||||
|
setEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-1 border-b border-gray-100 last:border-b-0 hover:bg-gray-50">
|
||||||
|
{/* 순서 변경 */}
|
||||||
|
<div className="flex flex-col ml-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMoveUp}
|
||||||
|
disabled={isFirst}
|
||||||
|
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onMoveDown}
|
||||||
|
disabled={isLast}
|
||||||
|
className="p-0.5 text-gray-300 hover:text-gray-500 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이름 */}
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex-1 flex items-center gap-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editValue}
|
||||||
|
onChange={(e) => setEditValue(e.target.value)}
|
||||||
|
onBlur={handleSave}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="flex-1 text-[11px] px-1.5 py-0.5 border border-blue-300 rounded focus:outline-none focus:ring-1 focus:ring-blue-400"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={handleSave} className="p-0.5 text-green-600">
|
||||||
|
<Check className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="flex-1 text-[11px] text-gray-700 cursor-pointer hover:text-blue-600 truncate"
|
||||||
|
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
|
||||||
|
>
|
||||||
|
{subItem.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 편집/삭제 */}
|
||||||
|
{!editing && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditValue(subItem.name); setEditing(true); }}
|
||||||
|
className="p-0.5 text-gray-300 hover:text-blue-500"
|
||||||
|
>
|
||||||
|
<Pencil className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="p-0.5 text-gray-300 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-2.5 w-2.5" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -11,6 +11,7 @@ interface Day1ChecklistPanelProps {
|
|||||||
searchTerm: string;
|
searchTerm: string;
|
||||||
onSubItemSelect: (categoryId: string, subItemId: string) => void;
|
onSubItemSelect: (categoryId: string, subItemId: string) => void;
|
||||||
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
|
onSubItemToggle: (categoryId: string, subItemId: string, isCompleted: boolean) => void;
|
||||||
|
isMock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Day1ChecklistPanel({
|
export function Day1ChecklistPanel({
|
||||||
@@ -19,6 +20,7 @@ export function Day1ChecklistPanel({
|
|||||||
searchTerm,
|
searchTerm,
|
||||||
onSubItemSelect,
|
onSubItemSelect,
|
||||||
onSubItemToggle,
|
onSubItemToggle,
|
||||||
|
isMock,
|
||||||
}: Day1ChecklistPanelProps) {
|
}: Day1ChecklistPanelProps) {
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
|
new Set(categories.map(c => c.id)) // 기본적으로 모두 펼침
|
||||||
@@ -48,6 +50,13 @@ export function Day1ChecklistPanel({
|
|||||||
}).filter((cat): cat is ChecklistCategory => cat !== null);
|
}).filter((cat): cat is ChecklistCategory => cat !== null);
|
||||||
}, [categories, searchTerm]);
|
}, [categories, searchTerm]);
|
||||||
|
|
||||||
|
// categories 로드 완료 시 모두 펼치기
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (categories.length > 0) {
|
||||||
|
setExpandedCategories(new Set(categories.map(c => c.id)));
|
||||||
|
}
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
// 검색 시 모든 카테고리 펼치기
|
// 검색 시 모든 카테고리 펼치기
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (searchTerm.trim()) {
|
if (searchTerm.trim()) {
|
||||||
@@ -95,7 +104,14 @@ export function Day1ChecklistPanel({
|
|||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">점검표 항목</h3>
|
||||||
|
{isMock && (
|
||||||
|
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||||
|
Mock
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{/* 검색 결과 카운트 */}
|
{/* 검색 결과 카운트 */}
|
||||||
{searchTerm && (
|
{searchTerm && (
|
||||||
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
|
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
|
||||||
@@ -114,7 +130,7 @@ export function Day1ChecklistPanel({
|
|||||||
검색 결과가 없습니다
|
검색 결과가 없습니다
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredCategories.map((category, categoryIndex) => {
|
filteredCategories.map((category, _categoryIndex) => {
|
||||||
const isExpanded = expandedCategories.has(category.id);
|
const isExpanded = expandedCategories.has(category.id);
|
||||||
const progress = getCategoryProgress(category);
|
const progress = getCategoryProgress(category);
|
||||||
const allCompleted = progress.completed === progress.total;
|
const allCompleted = progress.completed === progress.total;
|
||||||
|
|||||||
@@ -1,25 +1,45 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import { FileText, Download, Eye, CheckCircle2 } from 'lucide-react';
|
import { FileText, CheckCircle2, Upload, X, Loader2, Trash2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { Day1CheckItem, StandardDocument } from '../types';
|
import { toast } from 'sonner';
|
||||||
|
import type { Day1CheckItem, TemplateDocument } from '../types';
|
||||||
|
|
||||||
|
const ACCEPTED_EXTENSIONS = '.pdf,.xlsx,.xls,.doc,.docx,.hwp';
|
||||||
|
const ACCEPTED_MIME = [
|
||||||
|
'application/pdf',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/haansofthwp',
|
||||||
|
];
|
||||||
|
const MAX_FILE_SIZE_MB = 20;
|
||||||
|
|
||||||
interface Day1DocumentSectionProps {
|
interface Day1DocumentSectionProps {
|
||||||
checkItem: Day1CheckItem | null;
|
checkItem: Day1CheckItem | null;
|
||||||
selectedDocumentId: string | null;
|
|
||||||
onDocumentSelect: (documentId: string) => void;
|
|
||||||
onConfirmComplete: () => void;
|
onConfirmComplete: () => void;
|
||||||
isCompleted: boolean;
|
isCompleted: boolean;
|
||||||
|
isMock?: boolean;
|
||||||
|
onFileUpload?: (subItemId: string, file: File) => Promise<boolean>;
|
||||||
|
uploadedFiles?: TemplateDocument[];
|
||||||
|
onFileDelete?: (fileId: number) => void;
|
||||||
|
onFileSelect?: (file: TemplateDocument) => void;
|
||||||
|
selectedFileId?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Day1DocumentSection({
|
export function Day1DocumentSection({
|
||||||
checkItem,
|
checkItem,
|
||||||
selectedDocumentId,
|
|
||||||
onDocumentSelect,
|
|
||||||
onConfirmComplete,
|
onConfirmComplete,
|
||||||
isCompleted,
|
isCompleted,
|
||||||
|
isMock,
|
||||||
|
onFileUpload,
|
||||||
|
uploadedFiles = [],
|
||||||
|
onFileDelete,
|
||||||
|
onFileSelect,
|
||||||
|
selectedFileId,
|
||||||
}: Day1DocumentSectionProps) {
|
}: Day1DocumentSectionProps) {
|
||||||
if (!checkItem) {
|
if (!checkItem) {
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +56,14 @@ export function Day1DocumentSection({
|
|||||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200">
|
||||||
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">기준 문서화</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-semibold text-gray-900 text-sm sm:text-base">기준 문서화</h3>
|
||||||
|
{isMock && (
|
||||||
|
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||||
|
Mock
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 콘텐츠 */}
|
{/* 콘텐츠 */}
|
||||||
@@ -47,19 +74,23 @@ export function Day1DocumentSection({
|
|||||||
<p className="text-xs sm:text-sm text-blue-700">{checkItem.description}</p>
|
<p className="text-xs sm:text-sm text-blue-700">{checkItem.description}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기준 문서 목록 */}
|
{/* 관련 기준 문서 */}
|
||||||
<div>
|
<div>
|
||||||
<h5 className="text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2">관련 기준 문서</h5>
|
<h5 className="text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2">관련 기준 문서</h5>
|
||||||
<div className="space-y-1.5 sm:space-y-2">
|
<div className="space-y-1">
|
||||||
{checkItem.standardDocuments.map((doc) => (
|
{uploadedFiles.map((file) => (
|
||||||
<DocumentRow
|
<UploadedFileRow
|
||||||
key={doc.id}
|
key={file.id}
|
||||||
document={doc}
|
file={file}
|
||||||
isSelected={selectedDocumentId === doc.id}
|
isSelected={selectedFileId === file.id}
|
||||||
onSelect={() => onDocumentSelect(doc.id)}
|
onSelect={onFileSelect ? () => onFileSelect(file) : undefined}
|
||||||
|
onDelete={onFileDelete ? () => onFileDelete(file.id) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<DocumentUploadArea
|
||||||
|
onUpload={onFileUpload ? (file) => onFileUpload(checkItem.subItemId, file) : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 확인 버튼 */}
|
{/* 확인 버튼 */}
|
||||||
@@ -91,67 +122,200 @@ export function Day1DocumentSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DocumentRowProps {
|
// ===== 파일 업로드 영역 =====
|
||||||
document: StandardDocument;
|
|
||||||
isSelected: boolean;
|
interface DocumentUploadAreaProps {
|
||||||
onSelect: () => void;
|
onUpload?: (file: File) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) {
|
function DocumentUploadArea({ onUpload }: DocumentUploadAreaProps) {
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const validateFile = useCallback((file: File): string | null => {
|
||||||
|
const sizeMB = file.size / (1024 * 1024);
|
||||||
|
if (sizeMB > MAX_FILE_SIZE_MB) {
|
||||||
|
return `파일 크기는 ${MAX_FILE_SIZE_MB}MB 이하여야 합니다.`;
|
||||||
|
}
|
||||||
|
// 확장자 체크
|
||||||
|
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
const allowed = ['pdf', 'xlsx', 'xls', 'doc', 'docx', 'hwp'];
|
||||||
|
if (!ext || !allowed.includes(ext)) {
|
||||||
|
return 'PDF, Excel, Word, HWP 파일만 업로드 가능합니다.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFile = useCallback((file: File) => {
|
||||||
|
const error = validateFile(file);
|
||||||
|
if (error) {
|
||||||
|
toast.error(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingFile(file);
|
||||||
|
}, [validateFile]);
|
||||||
|
|
||||||
|
const handleConfirmUpload = useCallback(async () => {
|
||||||
|
if (!pendingFile || !onUpload) return;
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const success = await onUpload(pendingFile);
|
||||||
|
if (success) {
|
||||||
|
toast.success(`${pendingFile.name} 업로드 완료`);
|
||||||
|
setPendingFile(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
}, [pendingFile, onUpload]);
|
||||||
|
|
||||||
|
const handleCancelUpload = useCallback(() => {
|
||||||
|
setPendingFile(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsDragging(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file) handleFile(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 선택된 파일 미리보기
|
||||||
|
if (pendingFile) {
|
||||||
|
const ext = pendingFile.name.split('.').pop()?.toLowerCase();
|
||||||
|
const sizeMB = (pendingFile.size / (1024 * 1024)).toFixed(1);
|
||||||
|
return (
|
||||||
|
<div className="mt-2 p-2.5 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={cn(
|
||||||
|
'flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center',
|
||||||
|
ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||||
|
)}>
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs font-medium text-gray-800 truncate">{pendingFile.name}</p>
|
||||||
|
<p className="text-[10px] text-gray-500">{sizeMB} MB</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelUpload}
|
||||||
|
className="p-1 text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancelUpload}
|
||||||
|
className="flex-1 py-1.5 text-xs text-gray-600 bg-white border border-gray-200 rounded-md hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmUpload}
|
||||||
|
disabled={uploading}
|
||||||
|
className="flex-1 py-1.5 text-xs text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
{uploading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
업로드 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'업로드'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_EXTENSIONS}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
onDragEnter={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(true); }}
|
||||||
|
onDragLeave={(e) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 flex items-center justify-center gap-1.5 py-2.5 rounded-lg border-2 border-dashed cursor-pointer transition-colors',
|
||||||
|
isDragging
|
||||||
|
? 'border-blue-400 bg-blue-50'
|
||||||
|
: 'border-gray-200 hover:border-blue-300 hover:bg-gray-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5 text-gray-400" />
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
파일 업로드 (PDF, Excel, Word, HWP)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 업로드된 파일 행 =====
|
||||||
|
|
||||||
|
interface UploadedFileRowProps {
|
||||||
|
file: TemplateDocument;
|
||||||
|
isSelected?: boolean;
|
||||||
|
onSelect?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadedFileRow({ file, isSelected, onSelect, onDelete }: UploadedFileRowProps) {
|
||||||
|
const ext = file.displayName.split('.').pop()?.toLowerCase();
|
||||||
|
const sizeMB = (file.fileSize / (1024 * 1024)).toFixed(1);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border cursor-pointer transition-colors',
|
'flex items-center gap-2 p-2 rounded-lg border cursor-pointer transition-colors',
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-blue-50 border-blue-300'
|
? 'bg-blue-50 border-blue-300'
|
||||||
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
|
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
{/* 아이콘 */}
|
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex-shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center',
|
'flex-shrink-0 w-7 h-7 rounded flex items-center justify-center',
|
||||||
document.fileName?.endsWith('.pdf')
|
ext === 'pdf' ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||||
? 'bg-red-100 text-red-600'
|
|
||||||
: 'bg-green-100 text-green-600'
|
|
||||||
)}>
|
)}>
|
||||||
<FileText className="h-4 w-4 sm:h-5 sm:w-5" />
|
<FileText className="h-3.5 w-3.5" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 문서 정보 */}
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-gray-900 truncate">{document.title}</p>
|
<p className="text-xs font-medium text-gray-800 truncate">{file.displayName}</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-[10px] text-gray-400">{sizeMB} MB</p>
|
||||||
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
|
|
||||||
<span>{document.date}</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
{onDelete && (
|
||||||
{/* 액션 버튼 */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
|
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||||
title="미리보기"
|
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
|
||||||
onClick={(e) => {
|
title="삭제"
|
||||||
e.stopPropagation();
|
|
||||||
onSelect();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
)}
|
||||||
type="button"
|
|
||||||
className="p-1.5 rounded hover:bg-gray-200 text-gray-500 hover:text-blue-600 transition-colors"
|
|
||||||
title="다운로드"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
// TODO: 다운로드 기능
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3,13 +3,120 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
|
import { FileText, Download, Printer, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { StandardDocument } from '../types';
|
import type { StandardDocument, TemplateDocument } from '../types';
|
||||||
|
|
||||||
interface Day1DocumentViewerProps {
|
interface Day1DocumentViewerProps {
|
||||||
document: StandardDocument | null;
|
document: StandardDocument | null;
|
||||||
|
uploadedFile?: TemplateDocument | null;
|
||||||
|
isMock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
|
export function Day1DocumentViewer({ document, uploadedFile, isMock }: Day1DocumentViewerProps) {
|
||||||
|
// 업로드된 파일이 선택된 경우
|
||||||
|
if (uploadedFile) {
|
||||||
|
const isPdf = uploadedFile.mimeType === 'application/pdf';
|
||||||
|
const viewUrl = `/api/proxy/files/${uploadedFile.id}/view`;
|
||||||
|
const downloadUrl = `/api/proxy/files/${uploadedFile.id}/download`;
|
||||||
|
|
||||||
|
// Google Docs Viewer용 공개 URL 생성 (개발/운영 서버에서만 동작)
|
||||||
|
const isLocalhost = typeof window !== 'undefined' && (
|
||||||
|
window.location.hostname === 'localhost' ||
|
||||||
|
window.location.hostname.endsWith('.sam.kr')
|
||||||
|
);
|
||||||
|
const publicFileUrl = typeof window !== 'undefined'
|
||||||
|
? `${window.location.origin}${viewUrl}`
|
||||||
|
: '';
|
||||||
|
const googleViewerUrl = `https://docs.google.com/gview?url=${encodeURIComponent(publicFileUrl)}&embedded=true`;
|
||||||
|
|
||||||
|
const isOfficeDoc = [
|
||||||
|
'application/msword',
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
'application/vnd.ms-excel',
|
||||||
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
].includes(uploadedFile.mimeType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="bg-gray-100 px-2 sm:px-4 py-2 sm:py-3 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`w-6 h-6 sm:w-8 sm:h-8 rounded flex items-center justify-center ${
|
||||||
|
isPdf ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'
|
||||||
|
}`}>
|
||||||
|
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-900 text-xs sm:text-sm truncate max-w-[200px]">
|
||||||
|
{uploadedFile.displayName}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||||
|
{(uploadedFile.fileSize / (1024 * 1024)).toFixed(1)} MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<a
|
||||||
|
href={viewUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
title="새 탭에서 보기"
|
||||||
|
>
|
||||||
|
<Maximize2 className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||||
|
title="다운로드"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서 미리보기 */}
|
||||||
|
<div className="flex-1 bg-gray-200 overflow-auto">
|
||||||
|
{isPdf ? (
|
||||||
|
<iframe
|
||||||
|
src={viewUrl}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
title={uploadedFile.displayName}
|
||||||
|
/>
|
||||||
|
) : isOfficeDoc && !isLocalhost ? (
|
||||||
|
<iframe
|
||||||
|
src={googleViewerUrl}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
title={uploadedFile.displayName}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full text-gray-400">
|
||||||
|
<div className="text-center">
|
||||||
|
<FileText className="h-12 w-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">
|
||||||
|
{isOfficeDoc && isLocalhost
|
||||||
|
? '로컬 환경에서는 Office 문서 미리보기가 지원되지 않습니다'
|
||||||
|
: '미리보기를 지원하지 않는 파일 형식입니다'}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
className="inline-block mt-2 text-xs text-blue-600 hover:text-blue-800 underline"
|
||||||
|
>
|
||||||
|
다운로드하여 확인
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="bg-gray-100 px-2 sm:px-4 py-1.5 sm:py-2 border-t border-gray-200">
|
||||||
|
<span className="text-[10px] sm:text-xs text-gray-500 truncate">
|
||||||
|
{uploadedFile.displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
|
<div className="bg-white rounded-lg border border-gray-200 h-full flex items-center justify-center">
|
||||||
@@ -38,7 +145,14 @@ export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
|
|||||||
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
|
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
|
||||||
|
{isMock && (
|
||||||
|
<span className="bg-amber-100 text-amber-700 text-[9px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||||
|
Mock
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="text-[10px] sm:text-xs text-gray-500">
|
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||||
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
|
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
|
||||||
{document.date}
|
{document.date}
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
FileText, CheckCircle, ChevronDown, ChevronUp,
|
FileText, CheckCircle, ChevronDown, ChevronUp,
|
||||||
Eye, Truck, Calendar, ClipboardCheck, Box, FileCheck
|
Eye, Truck, Calendar, ClipboardCheck, Box, FileCheck,
|
||||||
|
Upload, Download, Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Document, DocumentItem } from '../types';
|
import { Document, DocumentItem } from '../types';
|
||||||
|
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||||
|
|
||||||
interface DocumentListProps {
|
interface DocumentListProps {
|
||||||
documents: Document[];
|
documents: Document[];
|
||||||
routeCode: string | null;
|
routeCode: string | null;
|
||||||
onViewDocument: (doc: Document, item?: DocumentItem) => void;
|
onViewDocument: (doc: Document, item?: DocumentItem) => void;
|
||||||
|
onQualityFileUpload?: (qualityDocumentId: string, file: File) => Promise<boolean>;
|
||||||
|
isMock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getIcon = (type: string) => {
|
const getIcon = (type: string) => {
|
||||||
@@ -27,11 +32,76 @@ const getIcon = (type: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentListProps) => {
|
/** 파일 크기를 읽기 쉬운 형식으로 변환 */
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes}B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentList = ({ documents, routeCode, onViewDocument, onQualityFileUpload, isMock }: DocumentListProps) => {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [uploadingDocId, setUploadingDocId] = useState<string | null>(null);
|
||||||
|
const [downloadingDocId, setDownloadingDocId] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const uploadTargetDocId = useRef<string | null>(null); // doc.id (상태 추적용)
|
||||||
|
const uploadApiDocId = useRef<string | null>(null); // 실제 DB ID (API 호출용)
|
||||||
|
|
||||||
|
// 품질관리서 파일 다운로드
|
||||||
|
const handleQualityDownload = async (doc: Document) => {
|
||||||
|
if (!doc.fileId) return;
|
||||||
|
setDownloadingDocId(doc.id);
|
||||||
|
try {
|
||||||
|
await downloadFileById(doc.fileId, doc.fileName);
|
||||||
|
} catch {
|
||||||
|
toast.error('파일 다운로드에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setDownloadingDocId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 업로드 버튼 클릭 → hidden input 트리거
|
||||||
|
const handleUploadClick = (e: React.MouseEvent, docId: string, apiDocId?: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
uploadTargetDocId.current = docId; // 상태 추적용
|
||||||
|
uploadApiDocId.current = apiDocId || docId; // API 호출용 (품질관리서는 items[0].id)
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 파일 선택 후 업로드 실행
|
||||||
|
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
const trackingId = uploadTargetDocId.current;
|
||||||
|
const apiId = uploadApiDocId.current;
|
||||||
|
if (!file || !trackingId || !apiId || !onQualityFileUpload) return;
|
||||||
|
|
||||||
|
setUploadingDocId(trackingId);
|
||||||
|
try {
|
||||||
|
const success = await onQualityFileUpload(apiId, file);
|
||||||
|
if (success) {
|
||||||
|
toast.success('파일이 업로드되었습니다.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setUploadingDocId(null);
|
||||||
|
uploadTargetDocId.current = null;
|
||||||
|
uploadApiDocId.current = null;
|
||||||
|
// input 초기화 (같은 파일 재선택 가능하도록)
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 문서 카테고리 클릭 핸들러
|
// 문서 카테고리 클릭 핸들러
|
||||||
const handleDocClick = (doc: Document) => {
|
const handleDocClick = (doc: Document) => {
|
||||||
|
// 품질관리서: 파일이 있으면 다운로드, 없으면 무시
|
||||||
|
if (doc.type === 'quality') {
|
||||||
|
if (doc.fileId) {
|
||||||
|
handleQualityDownload(doc);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const hasItems = doc.items && doc.items.length > 0;
|
const hasItems = doc.items && doc.items.length > 0;
|
||||||
if (!hasItems) return;
|
if (!hasItems) return;
|
||||||
|
|
||||||
@@ -50,52 +120,115 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
|
|||||||
onViewDocument(doc, item);
|
onViewDocument(doc, item);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 품질관리서 서브텍스트 렌더링
|
||||||
|
const renderQualitySubText = (doc: Document) => {
|
||||||
|
if (doc.fileId && doc.fileName) {
|
||||||
|
return (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Download size={10} className="text-purple-500" />
|
||||||
|
<span className="text-purple-600 truncate max-w-[150px]" title={doc.fileName}>
|
||||||
|
{doc.fileName}
|
||||||
|
</span>
|
||||||
|
{doc.fileSize && (
|
||||||
|
<span className="text-gray-400 ml-1">({formatFileSize(doc.fileSize)})</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span className="text-gray-400">파일 없음</span>;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
|
{/* hidden file input for quality document upload */}
|
||||||
관련 서류{' '}
|
<input
|
||||||
{routeCode && (
|
ref={fileInputRef}
|
||||||
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||||
|
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
|
||||||
|
관련 서류{' '}
|
||||||
|
{routeCode && (
|
||||||
|
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{isMock && (
|
||||||
|
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||||
|
Mock
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
||||||
{!routeCode ? (
|
{!routeCode ? (
|
||||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||||
수주루트를 선택해주세요.
|
수주로트를 선택해주세요.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
documents.map((doc) => {
|
documents.map((doc) => {
|
||||||
const isExpanded = expandedId === doc.id;
|
const isExpanded = expandedId === doc.id;
|
||||||
|
const isQuality = doc.type === 'quality';
|
||||||
const hasItems = doc.items && doc.items.length > 0;
|
const hasItems = doc.items && doc.items.length > 0;
|
||||||
const hasMultipleItems = doc.items && doc.items.length > 1;
|
const hasMultipleItems = doc.items && doc.items.length > 1;
|
||||||
|
const isUploading = uploadingDocId === doc.id;
|
||||||
|
const isDownloading = downloadingDocId === doc.id;
|
||||||
|
|
||||||
|
// 품질관리서: 파일 유무로 클릭 가능 여부 결정
|
||||||
|
// 나머지: 아이템 유무로 결정
|
||||||
|
const isClickable = isQuality ? !!doc.fileId : hasItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={doc.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
<div key={doc.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<div
|
<div
|
||||||
onClick={() => handleDocClick(doc)}
|
onClick={() => handleDocClick(doc)}
|
||||||
className={`p-3 sm:p-4 flex justify-between items-center transition-colors ${
|
className={`p-3 sm:p-4 flex justify-between items-center transition-colors ${
|
||||||
hasItems ? 'cursor-pointer hover:bg-gray-50' : 'cursor-default opacity-60'
|
isClickable ? 'cursor-pointer hover:bg-gray-50' : 'cursor-default opacity-60'
|
||||||
} ${isExpanded ? 'bg-green-50' : 'bg-white'}`}
|
} ${isExpanded ? 'bg-green-50' : 'bg-white'}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||||
<div className={`p-2 rounded-lg ${isExpanded ? 'bg-white' : 'bg-gray-100'}`}>
|
<div className={`p-2 rounded-lg flex-shrink-0 ${isExpanded ? 'bg-white' : 'bg-gray-100'}`}>
|
||||||
{getIcon(doc.type)}
|
{(isUploading || isDownloading) ? (
|
||||||
|
<Loader2 className="text-purple-600 animate-spin" size={20} />
|
||||||
|
) : (
|
||||||
|
getIcon(doc.type)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className="font-bold text-gray-800 text-sm">{doc.title}</h3>
|
<h3 className="font-bold text-gray-800 text-sm">{doc.title}</h3>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{doc.count > 0 ? `${doc.count}건의 서류` : '서류 없음'}
|
{isQuality
|
||||||
|
? renderQualitySubText(doc)
|
||||||
|
: doc.count > 0 ? `${doc.count}건의 서류` : '서류 없음'
|
||||||
|
}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{hasMultipleItems && (
|
|
||||||
isExpanded ? (
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
<ChevronUp size={16} className="text-gray-400" />
|
{/* 품질관리서 업로드 버튼 */}
|
||||||
) : (
|
{isQuality && onQualityFileUpload && doc.items?.[0]?.id && (
|
||||||
<ChevronDown size={16} className="text-gray-400" />
|
<button
|
||||||
)
|
type="button"
|
||||||
)}
|
onClick={(e) => handleUploadClick(e, doc.id, doc.items![0].id)}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="p-1.5 rounded-md hover:bg-purple-50 text-purple-500 hover:text-purple-700 transition-colors disabled:opacity-50"
|
||||||
|
title={doc.fileId ? '파일 교체' : '파일 업로드'}
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* 기존: 여러 아이템일 때 펼치기/접기 아이콘 */}
|
||||||
|
{!isQuality && hasMultipleItems && (
|
||||||
|
isExpanded ? (
|
||||||
|
<ChevronUp size={16} className="text-gray-400" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown size={16} className="text-gray-400" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isExpanded && hasMultipleItems && (
|
{isExpanded && hasMultipleItems && (
|
||||||
@@ -114,7 +247,7 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
|
|||||||
{item.code && (
|
{item.code && (
|
||||||
<>
|
<>
|
||||||
<span className="mx-1">|</span>
|
<span className="mx-1">|</span>
|
||||||
로트: {item.code}
|
{item.code}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,78 +1,130 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* InspectionModal (QMS 전용)
|
||||||
|
*
|
||||||
|
* 수입검사, 수주서, 납품확인서, 출고증, 품질관리서 등
|
||||||
|
* 아직 독립 모달이 없는 문서 타입만 처리.
|
||||||
|
*
|
||||||
|
* 작업일지(log), 중간검사(report), 제품검사(product)는
|
||||||
|
* 각각 WorkLogModal, InspectionReportModal, ProductInspectionViewModal로 분리됨.
|
||||||
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { AlertCircle, Loader2, Save } from 'lucide-react';
|
import { AlertCircle, Loader2, Save } from 'lucide-react';
|
||||||
import { DocumentViewer } from '@/components/document-system';
|
import { DocumentViewer } from '@/components/document-system';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Document, DocumentItem } from '../types';
|
import { Document, DocumentItem } from '../types';
|
||||||
import { MOCK_SHIPMENT_DETAIL } from '../mockData';
|
import { getDocumentDetail } from '../actions';
|
||||||
|
|
||||||
// 기존 문서 컴포넌트 import
|
// 기존 문서 컴포넌트 import
|
||||||
import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation';
|
import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation';
|
||||||
import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip';
|
import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip';
|
||||||
|
import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/types';
|
||||||
|
|
||||||
// 수주서 문서 컴포넌트 import
|
// 수주서 문서 컴포넌트 import
|
||||||
import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument';
|
import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument';
|
||||||
import type { ProductInfo } from '@/components/orders/documents/OrderDocumentModal';
|
|
||||||
import type { OrderItem } from '@/components/orders/actions';
|
|
||||||
|
|
||||||
// 품질검사 문서 컴포넌트 import
|
// 품질검사 문서 컴포넌트 import
|
||||||
import {
|
import {
|
||||||
ImportInspectionDocument,
|
ImportInspectionDocument,
|
||||||
JointbarInspectionDocument,
|
|
||||||
QualityDocumentUploader,
|
QualityDocumentUploader,
|
||||||
} from './documents';
|
} from './documents';
|
||||||
|
|
||||||
// 제품검사 성적서 (신규 양식) import
|
|
||||||
import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument';
|
|
||||||
import { mockReportInspectionItems } from '@/components/quality/InspectionManagement/mockData';
|
|
||||||
import type { InspectionReportDocument as InspectionReportDocumentType } from '@/components/quality/InspectionManagement/types';
|
|
||||||
import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument';
|
import type { ImportInspectionTemplate, ImportInspectionRef, InspectionItemValue } from './documents/ImportInspectionDocument';
|
||||||
|
|
||||||
// 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전)
|
|
||||||
import {
|
|
||||||
ScreenWorkLogContent,
|
|
||||||
SlatWorkLogContent,
|
|
||||||
BendingWorkLogContent,
|
|
||||||
ScreenInspectionContent,
|
|
||||||
SlatInspectionContent,
|
|
||||||
BendingInspectionContent,
|
|
||||||
} from '@/components/production/WorkOrders/documents';
|
|
||||||
import type { WorkOrder } from '@/components/production/WorkOrders/types';
|
|
||||||
|
|
||||||
// 검사 템플릿 API
|
// 검사 템플릿 API
|
||||||
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
|
import { getInspectionTemplate } from '@/components/material/ReceivingManagement/actions';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환
|
* 저장된 document.data (field_key 기반)를 ImportInspectionDocument의 initialValues로 변환
|
||||||
*
|
*
|
||||||
* field_key 패턴:
|
* 두 가지 저장 형식을 모두 지원:
|
||||||
* - {itemId}_n{1,2,3} → numeric 측정값
|
* - 정규화 형식: section_id + row_index로 항목 식별, field_key는 "n1", "n1_ok" 등
|
||||||
* - {itemId}_okng_n{1,2,3} → OK/NG 값
|
* - 레거시 형식: field_key에 item.id 포함, 예: "${itemId}_n1", "${itemId}_okng_n1"
|
||||||
* - {itemId}_result → 항목별 판정
|
|
||||||
*/
|
*/
|
||||||
function parseSavedDataToInitialValues(
|
function parseSavedDataToInitialValues(
|
||||||
tmpl: ImportInspectionTemplate,
|
tmpl: ImportInspectionTemplate,
|
||||||
docData: Array<{ field_key: string; field_value: string | null }>
|
docData: Array<{ field_key: string; field_value: string | null; section_id?: number | null; row_index?: number }>,
|
||||||
|
sections?: Array<{ id: number; items: Array<{ id: number }> }>
|
||||||
): InspectionItemValue[] {
|
): InspectionItemValue[] {
|
||||||
// field_key → value 맵 생성
|
// (sectionId, rowIndex) → inspectionItem.id 역매핑 구축
|
||||||
const dataMap = new Map<string, string>();
|
const reverseMap = new Map<string, string>();
|
||||||
|
if (sections) {
|
||||||
|
for (const section of sections) {
|
||||||
|
section.items.forEach((sItem, idx) => {
|
||||||
|
reverseMap.set(`${section.id}_${idx}`, String(sItem.id));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 정규화 형식: itemId → { field_key → value }
|
||||||
|
const normalizedMap = new Map<string, Map<string, string>>();
|
||||||
|
// 레거시 형식: "${itemId}_n1" → value
|
||||||
|
const legacyMap = new Map<string, string>();
|
||||||
|
|
||||||
for (const d of docData) {
|
for (const d of docData) {
|
||||||
if (d.field_value) dataMap.set(d.field_key, d.field_value);
|
if (!d.field_value) continue;
|
||||||
|
const key = d.field_key;
|
||||||
|
const val = d.field_value;
|
||||||
|
|
||||||
|
// 전역 필드는 스킵
|
||||||
|
if (key === 'overall_result' || key === 'footer_judgement') continue;
|
||||||
|
if (key === 'remark' || key === 'footer_remark') continue;
|
||||||
|
|
||||||
|
// 정규화 형식: section_id가 있으면 역매핑으로 item 찾기
|
||||||
|
if (d.section_id != null && reverseMap.size > 0) {
|
||||||
|
const itemId = reverseMap.get(`${d.section_id}_${d.row_index ?? 0}`);
|
||||||
|
if (itemId) {
|
||||||
|
if (!normalizedMap.has(itemId)) normalizedMap.set(itemId, new Map());
|
||||||
|
normalizedMap.get(itemId)!.set(key, val);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레거시 형식 fallback
|
||||||
|
legacyMap.set(key, val);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tmpl.inspectionItems.map((item) => {
|
return tmpl.inspectionItems.map((item) => {
|
||||||
const isOkng = item.measurementType === 'okng';
|
const isOkng = item.measurementType === 'okng';
|
||||||
const measurements: (number | 'OK' | 'NG' | null)[] = Array(item.measurementCount).fill(null);
|
const measurements: (number | 'OK' | 'NG' | null)[] = Array(item.measurementCount).fill(null);
|
||||||
|
|
||||||
|
// 정규화 형식 우선 시도
|
||||||
|
const nData = normalizedMap.get(item.id);
|
||||||
|
if (nData && nData.size > 0) {
|
||||||
|
for (let n = 0; n < item.measurementCount; n++) {
|
||||||
|
if (isOkng) {
|
||||||
|
const okVal = nData.get(`n${n + 1}_ok`);
|
||||||
|
const ngVal = nData.get(`n${n + 1}_ng`);
|
||||||
|
if (okVal === 'OK') measurements[n] = 'OK';
|
||||||
|
else if (ngVal === 'NG') measurements[n] = 'NG';
|
||||||
|
} else {
|
||||||
|
const val = nData.get(`n${n + 1}`);
|
||||||
|
if (val) {
|
||||||
|
const num = parseFloat(val);
|
||||||
|
measurements[n] = isNaN(num) ? null : num;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultVal = nData.get('value');
|
||||||
|
let result: 'OK' | 'NG' | null = null;
|
||||||
|
if (resultVal === '적합' || resultVal === 'ok') result = 'OK';
|
||||||
|
else if (resultVal === '부적합' || resultVal === 'ng') result = 'NG';
|
||||||
|
|
||||||
|
return { itemId: item.id, measurements, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레거시 형식 fallback
|
||||||
for (let n = 0; n < item.measurementCount; n++) {
|
for (let n = 0; n < item.measurementCount; n++) {
|
||||||
if (isOkng) {
|
if (isOkng) {
|
||||||
const val = dataMap.get(`${item.id}_okng_n${n + 1}`);
|
const val = legacyMap.get(`${item.id}_okng_n${n + 1}`);
|
||||||
if (val === 'ok') measurements[n] = 'OK';
|
if (val === 'ok') measurements[n] = 'OK';
|
||||||
else if (val === 'ng') measurements[n] = 'NG';
|
else if (val === 'ng') measurements[n] = 'NG';
|
||||||
} else {
|
} else {
|
||||||
const val = dataMap.get(`${item.id}_n${n + 1}`);
|
const val = legacyMap.get(`${item.id}_n${n + 1}`);
|
||||||
if (val) {
|
if (val) {
|
||||||
const num = parseFloat(val);
|
const num = parseFloat(val);
|
||||||
measurements[n] = isNaN(num) ? null : num;
|
measurements[n] = isNaN(num) ? null : num;
|
||||||
@@ -80,8 +132,7 @@ function parseSavedDataToInitialValues(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 항목별 판정
|
const resultVal = legacyMap.get(`${item.id}_result`);
|
||||||
const resultVal = dataMap.get(`${item.id}_result`);
|
|
||||||
let result: 'OK' | 'NG' | null = null;
|
let result: 'OK' | 'NG' | null = null;
|
||||||
if (resultVal === 'ok') result = 'OK';
|
if (resultVal === 'ok') result = 'OK';
|
||||||
else if (resultVal === 'ng') result = 'NG';
|
else if (resultVal === 'ng') result = 'NG';
|
||||||
@@ -96,15 +147,14 @@ interface InspectionModalProps {
|
|||||||
document: Document | null;
|
document: Document | null;
|
||||||
documentItem: DocumentItem | null;
|
documentItem: DocumentItem | null;
|
||||||
// 수입검사 템플릿 로드용 추가 props
|
// 수입검사 템플릿 로드용 추가 props
|
||||||
itemId?: number; // 품목 ID (실제 API로 템플릿 조회 시 사용)
|
itemId?: number;
|
||||||
itemName?: string;
|
itemName?: string;
|
||||||
specification?: string;
|
specification?: string;
|
||||||
supplier?: string;
|
supplier?: string;
|
||||||
inspector?: string; // 검사자 (현재 로그인 사용자)
|
inspector?: string;
|
||||||
inspectorDept?: string; // 검사자 부서
|
inspectorDept?: string;
|
||||||
lotSize?: number; // 로트크기 (입고수량)
|
lotSize?: number;
|
||||||
materialNo?: string; // 자재번호
|
materialNo?: string;
|
||||||
// 읽기 전용 모드 (QMS 심사 확인용)
|
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,11 +162,8 @@ interface InspectionModalProps {
|
|||||||
const DOCUMENT_INFO: Record<string, { label: string; hasTemplate: boolean; color: string }> = {
|
const DOCUMENT_INFO: Record<string, { label: string; hasTemplate: boolean; color: string }> = {
|
||||||
import: { label: '수입검사 성적서', hasTemplate: true, color: 'text-green-600' },
|
import: { label: '수입검사 성적서', hasTemplate: true, color: 'text-green-600' },
|
||||||
order: { label: '수주서', hasTemplate: true, color: 'text-blue-600' },
|
order: { label: '수주서', hasTemplate: true, color: 'text-blue-600' },
|
||||||
log: { label: '작업일지', hasTemplate: true, color: 'text-orange-500' },
|
|
||||||
report: { label: '중간검사 성적서', hasTemplate: true, color: 'text-blue-500' },
|
|
||||||
confirmation: { label: '납품확인서', hasTemplate: true, color: 'text-red-500' },
|
confirmation: { label: '납품확인서', hasTemplate: true, color: 'text-red-500' },
|
||||||
shipping: { label: '출고증', hasTemplate: true, color: 'text-gray-600' },
|
shipping: { label: '출고증', hasTemplate: true, color: 'text-gray-600' },
|
||||||
product: { label: '제품검사 성적서', hasTemplate: true, color: 'text-green-500' },
|
|
||||||
quality: { label: '품질관리서', hasTemplate: false, color: 'text-purple-600' },
|
quality: { label: '품질관리서', hasTemplate: false, color: 'text-purple-600' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,7 +183,7 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D
|
|||||||
)}
|
)}
|
||||||
{docItem?.code && (
|
{docItem?.code && (
|
||||||
<p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4">
|
<p className="text-xs text-green-600 font-mono bg-green-50 px-2 py-1 rounded mb-4">
|
||||||
로트 번호: {docItem.code}
|
문서번호: {docItem.code}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
|
<div className="mt-4 p-4 bg-amber-50 rounded-lg border border-amber-200">
|
||||||
@@ -147,77 +194,53 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// QMS용 수주서 Mock 데이터
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const QMS_MOCK_PRODUCTS: ProductInfo[] = [
|
type DocumentDetailData = Record<string, any>;
|
||||||
{ productName: '방화 스크린 셔터 (표준형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 5, floor: '1F', code: 'FSS-01' },
|
|
||||||
{ productName: '방화 스크린 셔터 (방화형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 3, floor: '2F', code: 'FSS-02' },
|
|
||||||
];
|
|
||||||
const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [
|
|
||||||
{ id: 'mt-1', itemCode: 'MT-001', itemName: '모터(380V 단상)', specification: '150K', type: '모터', quantity: 8, unit: 'EA', unitPrice: 120000, supplyAmount: 960000, taxAmount: 96000, totalAmount: 1056000, sortOrder: 1 },
|
|
||||||
{ id: 'br-1', itemCode: 'BR-001', itemName: '브라켓트', specification: '380X180 [2-4"]', type: '브라켓', quantity: 16, unit: 'EA', unitPrice: 15000, supplyAmount: 240000, taxAmount: 24000, totalAmount: 264000, sortOrder: 2 },
|
|
||||||
{ id: 'gr-1', itemCode: 'GR-001', itemName: '가이드레일 백면형 (120X70)', specification: 'EGI 1.5ST', type: '가이드레일', quantity: 16, unit: 'EA', unitPrice: 25000, supplyAmount: 400000, taxAmount: 40000, totalAmount: 440000, width: 120, height: 2500, sortOrder: 3 },
|
|
||||||
{ id: 'cs-1', itemCode: 'CS-001', itemName: '케이스(셔터박스)', specification: 'EGI 1.5ST 380X180', type: '케이스', quantity: 8, unit: 'EA', unitPrice: 35000, supplyAmount: 280000, taxAmount: 28000, totalAmount: 308000, width: 380, height: 180, sortOrder: 4 },
|
|
||||||
{ id: 'bf-1', itemCode: 'BF-001', itemName: '하단마감재', specification: 'EGI 1.5ST', type: '하단마감재', quantity: 8, unit: 'EA', unitPrice: 18000, supplyAmount: 144000, taxAmount: 14400, totalAmount: 158400, sortOrder: 5 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// QMS용 제품검사 성적서 Mock 데이터
|
/**
|
||||||
const QMS_MOCK_REPORT_DATA: InspectionReportDocumentType = {
|
* API 출고 상세 응답 → ShipmentDetail 타입 매핑
|
||||||
documentNumber: 'RPT-KD-SS-2024-530',
|
* ShipmentOrderDocument 내부에서 아직 MOCK_ 데이터를 사용하므로
|
||||||
createdDate: '2024-09-24',
|
* 여기서는 헤더 정보 매핑만 수행 (Phase 2에서 완전 전환)
|
||||||
approvalLine: [
|
*/
|
||||||
{ role: '작성', name: '김검사', department: '품질관리부' },
|
function mapShipmentApiToDetail(api: DocumentDetailData): ShipmentDetail {
|
||||||
{ role: '승인', name: '박승인', department: '품질관리부' },
|
return {
|
||||||
],
|
id: String(api.id || ''),
|
||||||
productName: '방화스크린',
|
shipmentNo: api.shipment_no || '-',
|
||||||
productLotNo: 'KD-SS-240924-19',
|
lotNo: api.lot_no || '-',
|
||||||
productCode: 'WY-SC780',
|
siteName: api.site_name || '-',
|
||||||
lotSize: '8',
|
customerName: api.customer_name || '-',
|
||||||
client: '삼성물산(주)',
|
customerGrade: api.customer_grade || '-',
|
||||||
inspectionDate: '2024-09-26',
|
status: api.status || 'scheduled',
|
||||||
siteName: '강남 아파트 단지',
|
scheduledDate: api.scheduled_date || '-',
|
||||||
inspector: '김검사',
|
deliveryMethod: api.delivery_method || 'loading',
|
||||||
inspectionItems: mockReportInspectionItems,
|
freightCost: api.shipping_cost,
|
||||||
specialNotes: '',
|
receiver: api.receiver,
|
||||||
finalJudgment: '합격',
|
receiverContact: api.receiver_contact,
|
||||||
};
|
deliveryAddress: api.delivery_address || '-',
|
||||||
|
vehicleNo: api.vehicle_no,
|
||||||
// QMS용 작업일지 Mock WorkOrder 생성
|
driverName: api.driver_name,
|
||||||
const createQmsMockWorkOrder = (subType?: string): WorkOrder => ({
|
driverContact: api.driver_contact,
|
||||||
id: 'qms-wo-1',
|
remarks: api.remarks,
|
||||||
workOrderNo: 'KD-WO-240924-01',
|
vehicleDispatches: (api.vehicle_dispatches || []).map((d: DocumentDetailData, i: number) => ({
|
||||||
lotNo: 'KD-SS-240924-19',
|
id: String(i),
|
||||||
processId: 1,
|
logisticsCompany: d.logistics_company || '-',
|
||||||
processName: subType === 'slat' ? '슬랫' : subType === 'bending' ? '절곡' : '스크린',
|
arrivalDateTime: d.arrival_datetime || '-',
|
||||||
processCode: subType || 'screen',
|
tonnage: d.tonnage || '-',
|
||||||
processType: (subType || 'screen') as 'screen' | 'slat' | 'bending',
|
vehicleNo: d.vehicle_no || '-',
|
||||||
status: 'in_progress',
|
driverContact: d.driver_contact || '-',
|
||||||
client: '삼성물산(주)',
|
remarks: d.remarks || '',
|
||||||
projectName: '강남 아파트 단지',
|
})),
|
||||||
dueDate: '2024-10-05',
|
// Phase 2: product_groups/other_parts 실 데이터 매핑 예정
|
||||||
assignee: '김작업',
|
productGroups: [],
|
||||||
assignees: [
|
otherParts: [],
|
||||||
{ id: '1', name: '김작업', isPrimary: true },
|
// 하위 호환 필드 (최소값)
|
||||||
{ id: '2', name: '이생산', isPrimary: false },
|
products: [],
|
||||||
],
|
priority: 'normal',
|
||||||
orderDate: '2024-09-20',
|
depositConfirmed: false,
|
||||||
scheduledDate: '2024-09-24',
|
invoiceIssued: false,
|
||||||
shipmentDate: '2024-10-04',
|
canShip: false,
|
||||||
salesOrderDate: '2024-09-18',
|
} as ShipmentDetail;
|
||||||
isAssigned: true,
|
}
|
||||||
isStarted: true,
|
|
||||||
priority: 3,
|
|
||||||
priorityLabel: '긴급',
|
|
||||||
shutterCount: 5,
|
|
||||||
department: '생산부',
|
|
||||||
items: [
|
|
||||||
{ id: '1', no: 1, status: 'completed', productName: '와이어 스크린', floorCode: '1층/FSS-01', specification: '3000×2500', quantity: 2, unit: 'EA', orderNodeId: null, orderNodeName: '' },
|
|
||||||
{ id: '2', no: 2, status: 'in_progress', productName: '메쉬 스크린', floorCode: '2층/FSS-03', specification: '3000×2500', quantity: 3, unit: 'EA', orderNodeId: null, orderNodeName: '' },
|
|
||||||
{ id: '3', no: 3, status: 'waiting', productName: '광폭 와이어', floorCode: '3층/FSS-05', specification: '12000×4500', quantity: 1, unit: 'EA', orderNodeId: null, orderNodeName: '' },
|
|
||||||
],
|
|
||||||
currentStep: 2,
|
|
||||||
issues: [],
|
|
||||||
note: '품질 검수 철저히 진행',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 로딩 컴포넌트
|
// 로딩 컴포넌트
|
||||||
const LoadingDocument = () => (
|
const LoadingDocument = () => (
|
||||||
@@ -245,9 +268,9 @@ const ErrorDocument = ({ message, onRetry }: { message: string; onRetry?: () =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* InspectionModal V2
|
* InspectionModal
|
||||||
* - DocumentViewer 시스템 사용
|
* - 수입검사, 수주서, 납품확인서, 출고증, 품질관리서만 처리
|
||||||
* - 수입검사: 모달 열릴 때 API로 템플릿 로드 (Lazy Loading)
|
* - 작업일지/중간검사/제품검사는 각각 독립 모달로 분리됨
|
||||||
*/
|
*/
|
||||||
export const InspectionModal = ({
|
export const InspectionModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -274,13 +297,38 @@ export const InspectionModal = ({
|
|||||||
const importDocRef = useRef<ImportInspectionRef>(null);
|
const importDocRef = useRef<ImportInspectionRef>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// 수주서/출고증/납품확인서 실 데이터 상태
|
||||||
|
const [docDetailData, setDocDetailData] = useState<DocumentDetailData | null>(null);
|
||||||
|
const [isLoadingDocDetail, setIsLoadingDocDetail] = useState(false);
|
||||||
|
|
||||||
|
// 수주서/출고증/납품확인서 데이터 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !doc) return;
|
||||||
|
if (!['order', 'confirmation', 'shipping'].includes(doc.type)) return;
|
||||||
|
|
||||||
|
const docItemId = documentItem?.id || doc.id;
|
||||||
|
if (!docItemId) return;
|
||||||
|
|
||||||
|
setIsLoadingDocDetail(true);
|
||||||
|
getDocumentDetail(doc.type, docItemId)
|
||||||
|
.then((result) => {
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const raw = result.data as DocumentDetailData;
|
||||||
|
setDocDetailData(raw?.data ?? raw);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setIsLoadingDocDetail(false));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
setDocDetailData(null);
|
||||||
|
};
|
||||||
|
}, [isOpen, doc?.type, doc?.id, documentItem?.id]);
|
||||||
|
|
||||||
// 수입검사 템플릿 로드 (모달 열릴 때)
|
// 수입검사 템플릿 로드 (모달 열릴 때)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// itemId가 있으면 실제 API로 조회, 없으면 itemName/specification으로 mock 조회
|
|
||||||
if (isOpen && doc?.type === 'import' && (itemId || (itemName && specification))) {
|
if (isOpen && doc?.type === 'import' && (itemId || (itemName && specification))) {
|
||||||
loadInspectionTemplate();
|
loadInspectionTemplate();
|
||||||
}
|
}
|
||||||
// 모달 닫힐 때 상태 초기화
|
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
setImportTemplate(null);
|
setImportTemplate(null);
|
||||||
setImportInitialValues(undefined);
|
setImportInitialValues(undefined);
|
||||||
@@ -289,7 +337,6 @@ export const InspectionModal = ({
|
|||||||
}, [isOpen, doc?.type, itemId, itemName, specification]);
|
}, [isOpen, doc?.type, itemId, itemName, specification]);
|
||||||
|
|
||||||
const loadInspectionTemplate = async () => {
|
const loadInspectionTemplate = async () => {
|
||||||
// itemId가 있으면 실제 API 호출, 없으면 itemName/specification 필요
|
|
||||||
if (!itemId && (!itemName || !specification)) return;
|
if (!itemId && (!itemName || !specification)) return;
|
||||||
|
|
||||||
setIsLoadingTemplate(true);
|
setIsLoadingTemplate(true);
|
||||||
@@ -311,10 +358,19 @@ export const InspectionModal = ({
|
|||||||
const tmpl = result.data as ImportInspectionTemplate;
|
const tmpl = result.data as ImportInspectionTemplate;
|
||||||
setImportTemplate(tmpl);
|
setImportTemplate(tmpl);
|
||||||
|
|
||||||
// 저장된 측정값을 initialValues로 변환
|
|
||||||
const docData = result.resolveData?.document?.data;
|
const docData = result.resolveData?.document?.data;
|
||||||
if (docData && docData.length > 0) {
|
if (docData && docData.length > 0) {
|
||||||
const values = parseSavedDataToInitialValues(tmpl, docData.map((d: { field_key: string; field_value?: string | null }) => ({ field_key: d.field_key, field_value: d.field_value ?? null })));
|
const sections = result.resolveData?.template?.sections;
|
||||||
|
const values = parseSavedDataToInitialValues(
|
||||||
|
tmpl,
|
||||||
|
docData.map((d: { field_key: string; field_value?: string | null; section_id?: number | null; row_index?: number }) => ({
|
||||||
|
field_key: d.field_key,
|
||||||
|
field_value: d.field_value ?? null,
|
||||||
|
section_id: d.section_id,
|
||||||
|
row_index: d.row_index,
|
||||||
|
})),
|
||||||
|
sections
|
||||||
|
);
|
||||||
setImportInitialValues(values);
|
setImportInitialValues(values);
|
||||||
} else {
|
} else {
|
||||||
setImportInitialValues(undefined);
|
setImportInitialValues(undefined);
|
||||||
@@ -330,11 +386,11 @@ export const InspectionModal = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함)
|
// 수입검사 저장 핸들러
|
||||||
const handleImportSave = useCallback(async () => {
|
const handleImportSave = useCallback(async () => {
|
||||||
if (!importDocRef.current) return;
|
if (!importDocRef.current) return;
|
||||||
|
|
||||||
const data = importDocRef.current.getInspectionData();
|
const _data = importDocRef.current.getInspectionData();
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
// TODO: 실제 저장 API 연동
|
// TODO: 실제 저장 API 연동
|
||||||
@@ -350,52 +406,16 @@ export const InspectionModal = ({
|
|||||||
|
|
||||||
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
|
const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' };
|
||||||
const subtitle = documentItem
|
const subtitle = documentItem
|
||||||
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` 로트: ${documentItem.code}` : ''}`
|
? `${docInfo.label} - ${documentItem.date}${documentItem.code ? ` ${documentItem.code}` : ''}`
|
||||||
: docInfo.label;
|
: docInfo.label;
|
||||||
|
|
||||||
// 품질관리서 PDF 업로드 핸들러
|
// 품질관리서 PDF 업로드 핸들러
|
||||||
const handleQualityFileUpload = (file: File) => {
|
const handleQualityFileUpload = (_file: File) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleQualityFileDelete = () => {
|
const handleQualityFileDelete = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// 작업일지 공정별 렌더링
|
|
||||||
const renderWorkLogDocument = () => {
|
|
||||||
const subType = documentItem?.subType;
|
|
||||||
const mockOrder = createQmsMockWorkOrder(subType);
|
|
||||||
|
|
||||||
switch (subType) {
|
|
||||||
case 'screen':
|
|
||||||
return <ScreenWorkLogContent data={mockOrder} />;
|
|
||||||
case 'slat':
|
|
||||||
return <SlatWorkLogContent data={mockOrder} />;
|
|
||||||
case 'bending':
|
|
||||||
return <BendingWorkLogContent data={mockOrder} />;
|
|
||||||
default:
|
|
||||||
// subType 미지정 시 스크린 기본
|
|
||||||
return <ScreenWorkLogContent data={mockOrder} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 중간검사 성적서 서브타입에 따른 렌더링 (신규 버전 통일)
|
|
||||||
const renderReportDocument = () => {
|
|
||||||
const subType = documentItem?.subType;
|
|
||||||
const mockOrder = createQmsMockWorkOrder(subType || 'screen');
|
|
||||||
switch (subType) {
|
|
||||||
case 'screen':
|
|
||||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
|
||||||
case 'bending':
|
|
||||||
return <BendingInspectionContent data={mockOrder} readOnly />;
|
|
||||||
case 'slat':
|
|
||||||
return <SlatInspectionContent data={mockOrder} readOnly />;
|
|
||||||
case 'jointbar':
|
|
||||||
return <JointbarInspectionDocument />;
|
|
||||||
default:
|
|
||||||
return <ScreenInspectionContent data={mockOrder} readOnly />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 수입검사 문서 렌더링 (Lazy Loading)
|
// 수입검사 문서 렌더링 (Lazy Loading)
|
||||||
const renderImportInspectionDocument = () => {
|
const renderImportInspectionDocument = () => {
|
||||||
if (isLoadingTemplate) {
|
if (isLoadingTemplate) {
|
||||||
@@ -406,7 +426,6 @@ export const InspectionModal = ({
|
|||||||
return <ErrorDocument message={templateError} onRetry={loadInspectionTemplate} />;
|
return <ErrorDocument message={templateError} onRetry={loadInspectionTemplate} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용
|
|
||||||
return (
|
return (
|
||||||
<ImportInspectionDocument
|
<ImportInspectionDocument
|
||||||
ref={importDocRef}
|
ref={importDocRef}
|
||||||
@@ -421,41 +440,47 @@ export const InspectionModal = ({
|
|||||||
// 문서 타입에 따른 컨텐츠 렌더링
|
// 문서 타입에 따른 컨텐츠 렌더링
|
||||||
const renderDocumentContent = () => {
|
const renderDocumentContent = () => {
|
||||||
switch (doc.type) {
|
switch (doc.type) {
|
||||||
case 'order':
|
case 'order': {
|
||||||
|
if (isLoadingDocDetail) return <LoadingDocument />;
|
||||||
|
if (!docDetailData) return <ErrorDocument message="수주서 데이터를 불러올 수 없습니다." />;
|
||||||
|
const d = docDetailData;
|
||||||
return (
|
return (
|
||||||
<SalesOrderDocument
|
<SalesOrderDocument
|
||||||
orderNumber="KD-SS-240924-19"
|
orderNumber={d.order_no || '-'}
|
||||||
documentNumber="KD-SS-240924-19"
|
documentNumber={d.order_no || '-'}
|
||||||
certificationNumber="KD-SS-240924-19"
|
certificationNumber={d.order_no || '-'}
|
||||||
orderDate="2024-09-24"
|
orderDate={d.received_at || '-'}
|
||||||
client="삼성물산(주)"
|
client={d.client_name || '-'}
|
||||||
siteName="강남 아파트 단지"
|
siteName={d.site_name || '-'}
|
||||||
manager="김담당"
|
manager={d.manager_name || '-'}
|
||||||
managerContact="010-1234-5678"
|
managerContact={d.client_contact || '-'}
|
||||||
deliveryRequestDate="2024-10-05"
|
deliveryRequestDate={d.delivery_date || '-'}
|
||||||
expectedShipDate="2024-10-04"
|
deliveryMethod={d.delivery_method_code || '-'}
|
||||||
deliveryMethod="직접배차"
|
address={[d.shipping_address, d.shipping_address_detail].filter(Boolean).join(' ') || '-'}
|
||||||
address="서울시 강남구 테헤란로 123"
|
recipientName={d.receiver || '-'}
|
||||||
recipientName="김인수"
|
recipientContact={d.receiver_contact || '-'}
|
||||||
recipientContact="010-9876-5432"
|
shutterCount={d.nodes_count || 0}
|
||||||
shutterCount={8}
|
remarks={d.remarks}
|
||||||
products={QMS_MOCK_PRODUCTS}
|
productRows={d.products || []}
|
||||||
items={QMS_MOCK_ORDER_ITEMS}
|
motorsLeft={d.motors?.left || []}
|
||||||
remarks="납기일 엄수 요청"
|
motorsRight={d.motors?.right || []}
|
||||||
|
bendingParts={d.bending_parts || []}
|
||||||
|
subsidiaryParts={d.subsidiary_parts || []}
|
||||||
|
categoryCode={d.category_code}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'log':
|
}
|
||||||
return renderWorkLogDocument();
|
|
||||||
case 'confirmation':
|
case 'confirmation':
|
||||||
return <DeliveryConfirmation data={MOCK_SHIPMENT_DETAIL} />;
|
|
||||||
case 'shipping':
|
case 'shipping':
|
||||||
return <ShippingSlip data={MOCK_SHIPMENT_DETAIL} />;
|
if (isLoadingDocDetail) return <LoadingDocument />;
|
||||||
|
if (!docDetailData) return <ErrorDocument message="출고 데이터를 불러올 수 없습니다." />;
|
||||||
|
// TODO Phase 2: ShipmentOrderDocument도 실 데이터로 전환 시 여기서 매핑
|
||||||
|
// 현재는 ShipmentOrderDocument 내부 mock data를 사용하되 헤더 정보만 전달
|
||||||
|
return doc.type === 'confirmation'
|
||||||
|
? <DeliveryConfirmation data={mapShipmentApiToDetail(docDetailData)} />
|
||||||
|
: <ShippingSlip data={mapShipmentApiToDetail(docDetailData)} />;
|
||||||
case 'import':
|
case 'import':
|
||||||
return renderImportInspectionDocument();
|
return renderImportInspectionDocument();
|
||||||
case 'product':
|
|
||||||
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
|
|
||||||
case 'report':
|
|
||||||
return renderReportDocument();
|
|
||||||
case 'quality':
|
case 'quality':
|
||||||
return (
|
return (
|
||||||
<QualityDocumentUploader
|
<QualityDocumentUploader
|
||||||
|
|||||||
@@ -8,13 +8,21 @@ interface ReportListProps {
|
|||||||
reports: InspectionReport[];
|
reports: InspectionReport[];
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
onSelect: (report: InspectionReport) => void;
|
onSelect: (report: InspectionReport) => void;
|
||||||
|
isMock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) => {
|
export const ReportList = ({ reports, selectedId, onSelect, isMock }: ReportListProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col">
|
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col">
|
||||||
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||||
<h2 className="font-bold text-sm sm:text-lg text-gray-800">품질관리서 목록</h2>
|
<div className="flex items-center gap-2">
|
||||||
|
<h2 className="font-bold text-sm sm:text-lg text-gray-800">품질관리서 목록</h2>
|
||||||
|
{isMock && (
|
||||||
|
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||||
|
Mock
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<span className="bg-blue-100 text-blue-800 text-[10px] sm:text-xs font-bold px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full">
|
<span className="bg-blue-100 text-blue-800 text-[10px] sm:text-xs font-bold px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full">
|
||||||
{reports.length}건
|
{reports.length}건
|
||||||
</span>
|
</span>
|
||||||
@@ -32,19 +40,20 @@ export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) =
|
|||||||
<div
|
<div
|
||||||
key={report.id}
|
key={report.id}
|
||||||
onClick={() => onSelect(report)}
|
onClick={() => onSelect(report)}
|
||||||
className={`rounded-lg p-3 sm:p-4 cursor-pointer relative hover:shadow-md transition-all ${
|
className={`rounded-lg p-3 sm:p-4 cursor-pointer hover:shadow-md transition-all ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-2 border-blue-500 bg-blue-50'
|
? 'border-2 border-blue-500 bg-blue-50'
|
||||||
: 'border border-gray-200 bg-white hover:border-blue-300'
|
: 'border border-gray-200 bg-white hover:border-blue-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="absolute top-3 sm:top-4 right-3 sm:right-4 text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded">
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
{report.quarter}
|
<h3 className={`font-bold text-sm sm:text-lg ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
|
||||||
|
{report.code}
|
||||||
|
</h3>
|
||||||
|
<span className="text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded whitespace-nowrap">
|
||||||
|
{report.quarter}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className={`font-bold text-sm sm:text-lg mb-1 ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
|
|
||||||
{report.code}
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs sm:text-base text-gray-700 font-medium mb-1">{report.siteName}</p>
|
<p className="text-xs sm:text-base text-gray-700 font-medium mb-1">{report.siteName}</p>
|
||||||
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3">인정품목: {report.item}</p>
|
<p className="text-xs sm:text-sm text-gray-500 mb-2 sm:mb-3">인정품목: {report.item}</p>
|
||||||
|
|
||||||
@@ -52,8 +61,8 @@ export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) =
|
|||||||
isSelected ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
|
isSelected ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
|
||||||
}`}>
|
}`}>
|
||||||
<Package size={16} />
|
<Package size={16} />
|
||||||
<span>수주루트 {report.routeCount}건</span>
|
<span>수주로트 {report.totalRoutes}건</span>
|
||||||
<span className="text-gray-400 text-xs ml-1">(총 {report.totalRoutes}개소)</span>
|
<span className="text-gray-400 text-xs ml-1">(확인 {report.routeCount}/{report.totalRoutes})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ interface RouteListProps {
|
|||||||
onSelect: (route: RouteItem) => void;
|
onSelect: (route: RouteItem) => void;
|
||||||
onToggleItem: (routeId: string, itemId: string, isCompleted: boolean) => void;
|
onToggleItem: (routeId: string, itemId: string, isCompleted: boolean) => void;
|
||||||
reportCode: string | null;
|
reportCode: string | null;
|
||||||
|
isMock?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode }: RouteListProps) => {
|
export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCode, isMock }: RouteListProps) => {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleClick = (route: RouteItem) => {
|
const handleClick = (route: RouteItem) => {
|
||||||
@@ -28,17 +29,24 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
|
<div className="flex items-center gap-2 mb-3 sm:mb-4">
|
||||||
수주루트 목록{' '}
|
<h2 className="font-bold text-gray-800 text-xs sm:text-sm">
|
||||||
{reportCode && (
|
수주로트 목록{' '}
|
||||||
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
|
{reportCode && (
|
||||||
|
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
{isMock && (
|
||||||
|
<span className="bg-amber-100 text-amber-700 text-[9px] sm:text-[10px] font-semibold px-1.5 py-0.5 rounded border border-amber-200">
|
||||||
|
Mock
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</h2>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
||||||
{routes.length === 0 ? (
|
{routes.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||||
{reportCode ? '수주루트가 없습니다.' : '품질관리서를 선택해주세요.'}
|
{reportCode ? '수주로트가 없습니다.' : '품질관리서를 선택해주세요.'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
routes.map((route) => {
|
routes.map((route) => {
|
||||||
@@ -72,8 +80,8 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mb-1">수주일: {route.date}</p>
|
<p className="text-xs text-gray-500 mb-0.5">수주일: {route.date || '-'}</p>
|
||||||
<p className="text-xs text-gray-500 mb-2">현장: {route.site}</p>
|
<p className="text-xs text-gray-500 mb-2">현장: {route.site || '-'}{route.client ? ` (${route.client})` : ''}</p>
|
||||||
<div className="inline-flex items-center gap-1 bg-gray-100 px-2 py-0.5 rounded text-xs text-gray-600">
|
<div className="inline-flex items-center gap-1 bg-gray-100 px-2 py-0.5 rounded text-xs text-gray-600">
|
||||||
<MapPin size={10} />
|
<MapPin size={10} />
|
||||||
<span>{route.locationCount}개소</span>
|
<span>{route.locationCount}개소</span>
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ export const ImportInspectionDocument = forwardRef<ImportInspectionRef, ImportIn
|
|||||||
});
|
});
|
||||||
|
|
||||||
// OK/NG 선택 핸들러
|
// OK/NG 선택 핸들러
|
||||||
const handleResultChange = useCallback((itemId: string, result: JudgmentResult) => {
|
const _handleResultChange = useCallback((itemId: string, result: JudgmentResult) => {
|
||||||
if (readOnly) return;
|
if (readOnly) return;
|
||||||
|
|
||||||
setValues((prev) => {
|
setValues((prev) => {
|
||||||
@@ -773,8 +773,8 @@ export const ImportInspectionDocument = forwardRef<ImportInspectionRef, ImportIn
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{inspectionItems.map((item, idx) => {
|
{inspectionItems.map((item, _idx) => {
|
||||||
const itemValue = values[item.id];
|
const _itemValue = values[item.id];
|
||||||
|
|
||||||
// 그룹핑 정보
|
// 그룹핑 정보
|
||||||
const hasCategory = !!item.subName;
|
const hasCategory = !!item.subName;
|
||||||
@@ -903,13 +903,13 @@ export const ImportInspectionDocument = forwardRef<ImportInspectionRef, ImportIn
|
|||||||
성적서로 대체
|
성적서로 대체
|
||||||
</td>
|
</td>
|
||||||
) : item.measurementType === 'single_value' ? (
|
) : item.measurementType === 'single_value' ? (
|
||||||
// 단일 입력 (colspan으로 합침)
|
// 단일 입력 (colspan으로 합침) - 저장된 값이 있으면 표시
|
||||||
<td
|
<td
|
||||||
className="border border-gray-400 px-2 py-1 text-center align-middle"
|
className="border border-gray-400 px-2 py-1 text-center align-middle"
|
||||||
colSpan={3}
|
colSpan={3}
|
||||||
rowSpan={isGroupItem ? itemRowSpan : 1}
|
rowSpan={isGroupItem ? itemRowSpan : 1}
|
||||||
>
|
>
|
||||||
<span className="text-gray-400 text-xs">( 입력 )</span>
|
{renderMeasurementInput(item.id, 0)}
|
||||||
</td>
|
</td>
|
||||||
) : item.measurementType === 'okng' ? (
|
) : item.measurementType === 'okng' ? (
|
||||||
// OK/NG 선택형 - n 값에 따라 열 개수 결정
|
// OK/NG 선택형 - n 값에 따라 열 개수 결정
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from 'react';
|
import React, { useState, useRef, useCallback } from 'react';
|
||||||
import { Upload, FileText, Download, Trash2, Eye, RefreshCw, X } from 'lucide-react';
|
import { Upload, FileText, Download, Trash2, Eye, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
export interface QualityDocumentFile {
|
export interface QualityDocumentFile {
|
||||||
|
|||||||
@@ -0,0 +1,236 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { ChecklistCategory } from '../types';
|
||||||
|
import { getChecklistTemplate, saveChecklistTemplate } from '../actions';
|
||||||
|
|
||||||
|
function generateId() {
|
||||||
|
return `item-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChecklistTemplate() {
|
||||||
|
const [templateId, setTemplateId] = useState<number | null>(null);
|
||||||
|
const [editCategories, setEditCategories] = useState<ChecklistCategory[]>([]);
|
||||||
|
const savedRef = useRef<ChecklistCategory[]>([]);
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
|
// === 초기 로드 ===
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
getChecklistTemplate('day1_audit')
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setTemplateId(result.data.id);
|
||||||
|
const cats = result.data.categories;
|
||||||
|
setEditCategories(structuredClone(cats));
|
||||||
|
savedRef.current = structuredClone(cats);
|
||||||
|
} else {
|
||||||
|
setError(result.error || '템플릿 로드 실패');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError('템플릿 로드 중 오류 발생');
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// === 변경 추적 ===
|
||||||
|
const markChanged = useCallback(() => setHasChanges(true), []);
|
||||||
|
|
||||||
|
// === 카테고리 CRUD ===
|
||||||
|
const addCategory = useCallback(() => {
|
||||||
|
setEditCategories(prev => [
|
||||||
|
...prev,
|
||||||
|
{ id: generateId(), title: '새 카테고리', subItems: [] },
|
||||||
|
]);
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
const updateCategoryTitle = useCallback((categoryId: string, title: string) => {
|
||||||
|
setEditCategories(prev =>
|
||||||
|
prev.map(cat => cat.id === categoryId ? { ...cat, title } : cat)
|
||||||
|
);
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
const deleteCategory = useCallback((categoryId: string) => {
|
||||||
|
setEditCategories(prev => prev.filter(cat => cat.id !== categoryId));
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
// === 카테고리 순서 변경 ===
|
||||||
|
const moveCategoryUp = useCallback((index: number) => {
|
||||||
|
if (index <= 0) return;
|
||||||
|
setEditCategories(prev => {
|
||||||
|
const next = [...prev];
|
||||||
|
[next[index - 1], next[index]] = [next[index], next[index - 1]];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
const moveCategoryDown = useCallback((index: number) => {
|
||||||
|
setEditCategories(prev => {
|
||||||
|
if (index >= prev.length - 1) return prev;
|
||||||
|
const next = [...prev];
|
||||||
|
[next[index], next[index + 1]] = [next[index + 1], next[index]];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
// === 하위 항목 CRUD ===
|
||||||
|
const addSubItem = useCallback((categoryId: string) => {
|
||||||
|
setEditCategories(prev =>
|
||||||
|
prev.map(cat => {
|
||||||
|
if (cat.id !== categoryId) return cat;
|
||||||
|
return {
|
||||||
|
...cat,
|
||||||
|
subItems: [
|
||||||
|
...cat.subItems,
|
||||||
|
{ id: generateId(), name: '새 항목', isCompleted: false },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
const updateSubItemName = useCallback((categoryId: string, subItemId: string, name: string) => {
|
||||||
|
setEditCategories(prev =>
|
||||||
|
prev.map(cat => {
|
||||||
|
if (cat.id !== categoryId) return cat;
|
||||||
|
return {
|
||||||
|
...cat,
|
||||||
|
subItems: cat.subItems.map(item =>
|
||||||
|
item.id === subItemId ? { ...item, name } : item
|
||||||
|
),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
const deleteSubItem = useCallback((categoryId: string, subItemId: string) => {
|
||||||
|
setEditCategories(prev =>
|
||||||
|
prev.map(cat => {
|
||||||
|
if (cat.id !== categoryId) return cat;
|
||||||
|
return {
|
||||||
|
...cat,
|
||||||
|
subItems: cat.subItems.filter(item => item.id !== subItemId),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
// === 하위 항목 순서 변경 ===
|
||||||
|
const moveSubItemUp = useCallback((categoryId: string, index: number) => {
|
||||||
|
if (index <= 0) return;
|
||||||
|
setEditCategories(prev =>
|
||||||
|
prev.map(cat => {
|
||||||
|
if (cat.id !== categoryId) return cat;
|
||||||
|
const items = [...cat.subItems];
|
||||||
|
[items[index - 1], items[index]] = [items[index], items[index - 1]];
|
||||||
|
return { ...cat, subItems: items };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
const moveSubItemDown = useCallback((categoryId: string, index: number) => {
|
||||||
|
setEditCategories(prev =>
|
||||||
|
prev.map(cat => {
|
||||||
|
if (cat.id !== categoryId) return cat;
|
||||||
|
if (index >= cat.subItems.length - 1) return cat;
|
||||||
|
const items = [...cat.subItems];
|
||||||
|
[items[index], items[index + 1]] = [items[index + 1], items[index]];
|
||||||
|
return { ...cat, subItems: items };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
markChanged();
|
||||||
|
}, [markChanged]);
|
||||||
|
|
||||||
|
// === 저장 ===
|
||||||
|
const saveTemplate = useCallback(async () => {
|
||||||
|
if (!templateId) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// API용 데이터: isCompleted 제거
|
||||||
|
const apiCategories = editCategories.map(cat => ({
|
||||||
|
id: cat.id,
|
||||||
|
title: cat.title,
|
||||||
|
subItems: cat.subItems.map(item => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const result = await saveChecklistTemplate(templateId, {
|
||||||
|
categories: apiCategories,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success && result.data) {
|
||||||
|
const cats = result.data.categories;
|
||||||
|
setEditCategories(structuredClone(cats));
|
||||||
|
savedRef.current = structuredClone(cats);
|
||||||
|
setHasChanges(false);
|
||||||
|
toast.success('점검표가 저장되었습니다.');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [editCategories, templateId]);
|
||||||
|
|
||||||
|
// === 초기화 ===
|
||||||
|
const resetToSaved = useCallback(() => {
|
||||||
|
setEditCategories(structuredClone(savedRef.current));
|
||||||
|
setHasChanges(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 데이터
|
||||||
|
templateId,
|
||||||
|
editCategories,
|
||||||
|
hasChanges,
|
||||||
|
saving,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
|
||||||
|
// 카테고리
|
||||||
|
addCategory,
|
||||||
|
updateCategoryTitle,
|
||||||
|
deleteCategory,
|
||||||
|
moveCategoryUp,
|
||||||
|
moveCategoryDown,
|
||||||
|
|
||||||
|
// 하위 항목
|
||||||
|
addSubItem,
|
||||||
|
updateSubItemName,
|
||||||
|
deleteSubItem,
|
||||||
|
moveSubItemUp,
|
||||||
|
moveSubItemDown,
|
||||||
|
|
||||||
|
// 저장/초기화
|
||||||
|
saveTemplate,
|
||||||
|
resetToSaved,
|
||||||
|
};
|
||||||
|
}
|
||||||
147
src/app/[locale]/(protected)/quality/qms/hooks/useDay1Audit.ts
Normal file
147
src/app/[locale]/(protected)/quality/qms/hooks/useDay1Audit.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { ChecklistCategory } from '../types';
|
||||||
|
import { getChecklistTemplate, toggleTemplateItem } from '../actions';
|
||||||
|
|
||||||
|
export function useDay1Audit() {
|
||||||
|
// 데이터 상태
|
||||||
|
const [templateId, setTemplateId] = useState<number | null>(null);
|
||||||
|
const [categories, setCategories] = useState<ChecklistCategory[]>([]);
|
||||||
|
|
||||||
|
// 선택 상태
|
||||||
|
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
const [loadingChecklist, setLoadingChecklist] = useState(true);
|
||||||
|
const [pendingToggleIds, setPendingToggleIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 마운트 시 점검표 로드 (checklist_templates API)
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingChecklist(true);
|
||||||
|
|
||||||
|
getChecklistTemplate('day1_audit')
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setTemplateId(result.data.id);
|
||||||
|
setCategories(result.data.categories);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoadingChecklist(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 진행률 계산
|
||||||
|
const day1Progress = useMemo(() => {
|
||||||
|
const total = categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
|
||||||
|
const completed = categories.reduce(
|
||||||
|
(sum, cat) => sum + cat.subItems.filter((item) => item.isCompleted).length,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return { completed, total };
|
||||||
|
}, [categories]);
|
||||||
|
|
||||||
|
// 선택된 항목의 완료 여부
|
||||||
|
const isSelectedItemCompleted = useMemo(() => {
|
||||||
|
if (!selectedSubItemId) return false;
|
||||||
|
for (const cat of categories) {
|
||||||
|
const item = cat.subItems.find((sub) => sub.id === selectedSubItemId);
|
||||||
|
if (item) return item.isCompleted;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}, [categories, selectedSubItemId]);
|
||||||
|
|
||||||
|
// 선택된 점검 항목 정보 (중앙 패널용)
|
||||||
|
const selectedCheckItem = useMemo(() => {
|
||||||
|
if (!selectedSubItemId || !selectedCategoryId) return null;
|
||||||
|
const category = categories.find((c) => c.id === selectedCategoryId);
|
||||||
|
if (!category) return null;
|
||||||
|
const subItem = category.subItems.find((s) => s.id === selectedSubItemId);
|
||||||
|
if (!subItem) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: `check-${subItem.id}`,
|
||||||
|
categoryId: category.id,
|
||||||
|
subItemId: subItem.id,
|
||||||
|
title: subItem.name,
|
||||||
|
description: '',
|
||||||
|
buttonLabel: '기준/매뉴얼 확인',
|
||||||
|
standardDocuments: [],
|
||||||
|
};
|
||||||
|
}, [selectedSubItemId, selectedCategoryId, categories]);
|
||||||
|
|
||||||
|
// === 핸들러 ===
|
||||||
|
|
||||||
|
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
|
||||||
|
setSelectedCategoryId(categoryId);
|
||||||
|
setSelectedSubItemId(subItemId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubItemToggle = useCallback(async (categoryId: string, subItemId: string) => {
|
||||||
|
if (!templateId || pendingToggleIds.has(subItemId)) return;
|
||||||
|
setPendingToggleIds((prev) => new Set(prev).add(subItemId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await toggleTemplateItem(templateId, subItemId);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setCategories((prev) =>
|
||||||
|
prev.map((cat) => {
|
||||||
|
if (cat.id !== categoryId) return cat;
|
||||||
|
return {
|
||||||
|
...cat,
|
||||||
|
subItems: cat.subItems.map((item) => {
|
||||||
|
if (item.id !== subItemId) return item;
|
||||||
|
return { ...item, isCompleted: result.data!.isCompleted };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '항목 상태 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPendingToggleIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(subItemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [templateId, pendingToggleIds]);
|
||||||
|
|
||||||
|
const handleConfirmComplete = useCallback(() => {
|
||||||
|
if (selectedCategoryId && selectedSubItemId) {
|
||||||
|
handleSubItemToggle(selectedCategoryId, selectedSubItemId);
|
||||||
|
}
|
||||||
|
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 데이터
|
||||||
|
templateId,
|
||||||
|
categories,
|
||||||
|
day1Progress,
|
||||||
|
selectedCheckItem,
|
||||||
|
isSelectedItemCompleted,
|
||||||
|
|
||||||
|
// 선택
|
||||||
|
selectedSubItemId,
|
||||||
|
handleSubItemSelect,
|
||||||
|
|
||||||
|
// 토글
|
||||||
|
handleSubItemToggle,
|
||||||
|
handleConfirmComplete,
|
||||||
|
pendingToggleIds,
|
||||||
|
|
||||||
|
// 로딩
|
||||||
|
loadingChecklist,
|
||||||
|
|
||||||
|
// Mock 여부
|
||||||
|
isMock: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { InspectionReport, RouteItem, Document, DocumentItem } from '../types';
|
||||||
|
import {
|
||||||
|
getQualityReports,
|
||||||
|
getReportRoutes,
|
||||||
|
getRouteDocuments,
|
||||||
|
confirmUnitInspection,
|
||||||
|
} from '../actions';
|
||||||
|
|
||||||
|
const USE_MOCK = false;
|
||||||
|
|
||||||
|
export function useDay2LotAudit() {
|
||||||
|
// 필터 상태
|
||||||
|
const [selectedYear, setSelectedYear] = useState(2026);
|
||||||
|
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
|
// 데이터 상태
|
||||||
|
const [reports, setReports] = useState<InspectionReport[]>([]);
|
||||||
|
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>({});
|
||||||
|
const [documents, setDocuments] = useState<Document[]>([]);
|
||||||
|
|
||||||
|
// 선택 상태
|
||||||
|
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
|
||||||
|
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
||||||
|
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null);
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
const [loadingReports, setLoadingReports] = useState(false);
|
||||||
|
const [loadingRoutes, setLoadingRoutes] = useState(false);
|
||||||
|
const [loadingDocuments, setLoadingDocuments] = useState(false);
|
||||||
|
const [pendingConfirmIds, setPendingConfirmIds] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 마운트 시 + 필터 변경 시 보고서 자동 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (USE_MOCK) return;
|
||||||
|
|
||||||
|
const loadReports = async () => {
|
||||||
|
setLoadingReports(true);
|
||||||
|
try {
|
||||||
|
const quarterNum = selectedQuarter !== '전체'
|
||||||
|
? parseInt(selectedQuarter.replace('Q', ''))
|
||||||
|
: undefined;
|
||||||
|
const result = await getQualityReports({
|
||||||
|
year: selectedYear,
|
||||||
|
quarter: quarterNum,
|
||||||
|
q: searchTerm || undefined,
|
||||||
|
});
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setReports(result.data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingReports(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadReports();
|
||||||
|
}, [selectedYear, selectedQuarter, searchTerm]);
|
||||||
|
|
||||||
|
// 진행률 계산
|
||||||
|
const day2Progress = useMemo(() => {
|
||||||
|
let completed = 0;
|
||||||
|
let total = 0;
|
||||||
|
Object.values(routesData).forEach((routes) => {
|
||||||
|
routes.forEach((route) => {
|
||||||
|
route.subItems.forEach((item) => {
|
||||||
|
total++;
|
||||||
|
if (item.isCompleted) completed++;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { completed, total };
|
||||||
|
}, [routesData]);
|
||||||
|
|
||||||
|
// 필터링된 보고서 (API에서 이미 필터링되므로 그대로 반환)
|
||||||
|
const filteredReports = useMemo(() => {
|
||||||
|
return reports;
|
||||||
|
}, [reports]);
|
||||||
|
|
||||||
|
// 현재 루트/문서
|
||||||
|
const currentRoutes = useMemo(() => {
|
||||||
|
if (!selectedReport) return [];
|
||||||
|
return routesData[selectedReport.id] || [];
|
||||||
|
}, [selectedReport, routesData]);
|
||||||
|
|
||||||
|
const currentDocuments = useMemo(() => {
|
||||||
|
return documents;
|
||||||
|
}, [documents]);
|
||||||
|
|
||||||
|
// === API 호출 핸들러 ===
|
||||||
|
|
||||||
|
const handleReportSelect = useCallback(async (report: InspectionReport) => {
|
||||||
|
setSelectedReport(report);
|
||||||
|
setSelectedRoute(null);
|
||||||
|
setDocuments([]);
|
||||||
|
|
||||||
|
setLoadingRoutes(true);
|
||||||
|
try {
|
||||||
|
const result = await getReportRoutes(report.id);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setRoutesData((prev) => ({ ...prev, [report.id]: result.data! }));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingRoutes(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleRouteSelect = useCallback(async (route: RouteItem) => {
|
||||||
|
setSelectedRoute(route);
|
||||||
|
|
||||||
|
setLoadingDocuments(true);
|
||||||
|
try {
|
||||||
|
const result = await getRouteDocuments(route.id);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setDocuments(result.data);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingDocuments(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleViewDocument = useCallback((doc: Document, item?: DocumentItem) => {
|
||||||
|
// 품질관리서는 파일 다운로드로 처리 (DocumentList에서 직접 처리하므로 모달 열지 않음)
|
||||||
|
if (doc.type === 'quality') return;
|
||||||
|
|
||||||
|
setSelectedDoc(doc);
|
||||||
|
setSelectedDocItem(item || null);
|
||||||
|
setModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleItem = useCallback(async (routeId: string, itemId: string, isCompleted: boolean) => {
|
||||||
|
// API: 비관적 업데이트
|
||||||
|
if (pendingConfirmIds.has(itemId)) return;
|
||||||
|
setPendingConfirmIds((prev) => new Set(prev).add(itemId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await confirmUnitInspection(itemId, isCompleted);
|
||||||
|
if (result.success && result.data) {
|
||||||
|
setRoutesData((prev) => {
|
||||||
|
const newData = { ...prev };
|
||||||
|
for (const reportId of Object.keys(newData)) {
|
||||||
|
newData[reportId] = newData[reportId].map((route) => {
|
||||||
|
if (route.id !== routeId) return route;
|
||||||
|
return {
|
||||||
|
...route,
|
||||||
|
subItems: route.subItems.map((item) => {
|
||||||
|
if (item.id !== itemId) return item;
|
||||||
|
return { ...item, isCompleted: result.data!.isCompleted };
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '확인 상태 변경에 실패했습니다.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setPendingConfirmIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(itemId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [pendingConfirmIds]);
|
||||||
|
|
||||||
|
// 품질관리서 파일 정보 업데이트 (업로드 성공 후 documents 상태 반영)
|
||||||
|
// 품질관리서는 doc.id가 'quality' 문자열이므로 type으로 매칭
|
||||||
|
const updateQualityDocumentFile = useCallback((_docId: string, fileInfo: { fileId: number; fileName: string; fileSize: number }) => {
|
||||||
|
setDocuments((prev) =>
|
||||||
|
prev.map((doc) =>
|
||||||
|
doc.type === 'quality'
|
||||||
|
? { ...doc, fileId: fileInfo.fileId, fileName: fileInfo.fileName, fileSize: fileInfo.fileSize }
|
||||||
|
: doc
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleYearChange = useCallback((year: number) => {
|
||||||
|
setSelectedYear(year);
|
||||||
|
setSelectedReport(null);
|
||||||
|
setSelectedRoute(null);
|
||||||
|
setDocuments([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleQuarterChange = useCallback((quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
|
||||||
|
setSelectedQuarter(quarter);
|
||||||
|
setSelectedReport(null);
|
||||||
|
setSelectedRoute(null);
|
||||||
|
setDocuments([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((term: string) => {
|
||||||
|
setSearchTerm(term);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 필터
|
||||||
|
selectedYear,
|
||||||
|
selectedQuarter,
|
||||||
|
searchTerm,
|
||||||
|
handleYearChange,
|
||||||
|
handleQuarterChange,
|
||||||
|
handleSearchChange,
|
||||||
|
|
||||||
|
// 데이터
|
||||||
|
filteredReports,
|
||||||
|
currentRoutes,
|
||||||
|
currentDocuments,
|
||||||
|
day2Progress,
|
||||||
|
|
||||||
|
// 선택
|
||||||
|
selectedReport,
|
||||||
|
selectedRoute,
|
||||||
|
handleReportSelect,
|
||||||
|
handleRouteSelect,
|
||||||
|
|
||||||
|
// 모달
|
||||||
|
modalOpen,
|
||||||
|
selectedDoc,
|
||||||
|
selectedDocItem,
|
||||||
|
handleViewDocument,
|
||||||
|
setModalOpen,
|
||||||
|
|
||||||
|
// 토글
|
||||||
|
handleToggleItem,
|
||||||
|
pendingConfirmIds,
|
||||||
|
|
||||||
|
// 품질관리서 파일
|
||||||
|
updateQualityDocumentFile,
|
||||||
|
|
||||||
|
// 로딩
|
||||||
|
loadingReports,
|
||||||
|
loadingRoutes,
|
||||||
|
loadingDocuments,
|
||||||
|
|
||||||
|
// Mock 여부
|
||||||
|
isMock: USE_MOCK,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/ty
|
|||||||
export const MOCK_WORK_ORDER: WorkOrder = {
|
export const MOCK_WORK_ORDER: WorkOrder = {
|
||||||
id: 'wo-1',
|
id: 'wo-1',
|
||||||
orderNo: 'KD-WO-240924-01',
|
orderNo: 'KD-WO-240924-01',
|
||||||
|
productCode: 'WY-SC780',
|
||||||
productName: '스크린 셔터 (표준형)',
|
productName: '스크린 셔터 (표준형)',
|
||||||
processCode: 'screen',
|
processCode: 'screen',
|
||||||
processName: 'screen',
|
processName: 'screen',
|
||||||
@@ -97,13 +98,14 @@ export const MOCK_REPORTS: InspectionReport[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// 수주루트 목록 (reportId로 연결)
|
// 수주로트 목록 (reportId로 연결)
|
||||||
export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
||||||
'1': [
|
'1': [
|
||||||
{
|
{
|
||||||
id: '1-1',
|
id: '1-1',
|
||||||
code: 'KD-SS-240924-19',
|
code: 'KD-SS-240924-19',
|
||||||
date: '2024-09-24',
|
date: '2024-09-24',
|
||||||
|
client: '(주)강남건설',
|
||||||
site: '강남 아파트 A동',
|
site: '강남 아파트 A동',
|
||||||
locationCount: 7,
|
locationCount: 7,
|
||||||
subItems: [
|
subItems: [
|
||||||
@@ -120,6 +122,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
|||||||
id: '1-2',
|
id: '1-2',
|
||||||
code: 'KD-SS-241024-15',
|
code: 'KD-SS-241024-15',
|
||||||
date: '2024-10-24',
|
date: '2024-10-24',
|
||||||
|
client: '(주)강남건설',
|
||||||
site: '강남 아파트 B동',
|
site: '강남 아파트 B동',
|
||||||
locationCount: 7,
|
locationCount: 7,
|
||||||
subItems: [
|
subItems: [
|
||||||
@@ -133,6 +136,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
|||||||
id: '2-1',
|
id: '2-1',
|
||||||
code: 'SC-AP-241101-01',
|
code: 'SC-AP-241101-01',
|
||||||
date: '2024-11-01',
|
date: '2024-11-01',
|
||||||
|
client: '서초개발(주)',
|
||||||
site: '서초 오피스텔 본관',
|
site: '서초 오피스텔 본관',
|
||||||
locationCount: 8,
|
locationCount: 8,
|
||||||
subItems: [
|
subItems: [
|
||||||
@@ -146,6 +150,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
|||||||
id: '3-1',
|
id: '3-1',
|
||||||
code: 'SP-CW-240801-01',
|
code: 'SP-CW-240801-01',
|
||||||
date: '2024-08-01',
|
date: '2024-08-01',
|
||||||
|
client: '송파건설(주)',
|
||||||
site: '송파 주상복합 A타워',
|
site: '송파 주상복합 A타워',
|
||||||
locationCount: 10,
|
locationCount: 10,
|
||||||
subItems: [
|
subItems: [
|
||||||
@@ -156,6 +161,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
|||||||
id: '3-2',
|
id: '3-2',
|
||||||
code: 'SP-CW-240815-02',
|
code: 'SP-CW-240815-02',
|
||||||
date: '2024-08-15',
|
date: '2024-08-15',
|
||||||
|
client: '송파건설(주)',
|
||||||
site: '송파 주상복합 B타워',
|
site: '송파 주상복합 B타워',
|
||||||
locationCount: 8,
|
locationCount: 8,
|
||||||
subItems: [],
|
subItems: [],
|
||||||
@@ -164,6 +170,7 @@ export const MOCK_ROUTES_INITIAL: Record<string, RouteItem[]> = {
|
|||||||
id: '3-3',
|
id: '3-3',
|
||||||
code: 'SP-CW-240901-03',
|
code: 'SP-CW-240901-03',
|
||||||
date: '2024-09-01',
|
date: '2024-09-01',
|
||||||
|
client: '송파건설(주)',
|
||||||
site: '송파 주상복합 상가동',
|
site: '송파 주상복합 상가동',
|
||||||
locationCount: 3,
|
locationCount: 3,
|
||||||
subItems: [],
|
subItems: [],
|
||||||
|
|||||||
@@ -1,28 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useMemo, useCallback } from 'react';
|
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Filters } from './components/Filters';
|
import { Filters } from './components/Filters';
|
||||||
import { ReportList } from './components/ReportList';
|
import { ReportList } from './components/ReportList';
|
||||||
import { RouteList } from './components/RouteList';
|
import { RouteList } from './components/RouteList';
|
||||||
import { DocumentList } from './components/DocumentList';
|
import { DocumentList } from './components/DocumentList';
|
||||||
// import { InspectionModal } from './components/InspectionModal';
|
|
||||||
import { InspectionModal } from './components/InspectionModal';
|
import { InspectionModal } from './components/InspectionModal';
|
||||||
|
import { InspectionReportModal } from '@/components/production/WorkOrders/documents';
|
||||||
|
import { WorkLogModal } from '@/components/production/WorkOrders/documents';
|
||||||
|
import { ProductInspectionViewModal } from '@/components/quality/InspectionManagement/ProductInspectionViewModal';
|
||||||
|
import { getDocumentDetail } from './actions';
|
||||||
import { DayTabs } from './components/DayTabs';
|
import { DayTabs } from './components/DayTabs';
|
||||||
import { Day1ChecklistPanel } from './components/Day1ChecklistPanel';
|
import { Day1ChecklistPanel } from './components/Day1ChecklistPanel';
|
||||||
import { Day1DocumentSection } from './components/Day1DocumentSection';
|
import { Day1DocumentSection } from './components/Day1DocumentSection';
|
||||||
import { Day1DocumentViewer } from './components/Day1DocumentViewer';
|
import { Day1DocumentViewer } from './components/Day1DocumentViewer';
|
||||||
import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel';
|
import { AuditSettingsPanel, SettingsButton, type AuditDisplaySettings } from './components/AuditSettingsPanel';
|
||||||
import { InspectionReport, RouteItem, Document, DocumentItem, ChecklistCategory } from './types';
|
import { useDay1Audit } from './hooks/useDay1Audit';
|
||||||
import {
|
import { useDay2LotAudit } from './hooks/useDay2LotAudit';
|
||||||
MOCK_REPORTS,
|
import { useChecklistTemplate } from './hooks/useChecklistTemplate';
|
||||||
MOCK_ROUTES_INITIAL,
|
import { uploadTemplateDocument, getTemplateDocuments, deleteTemplateDocument, uploadQualityDocumentFile } from './actions';
|
||||||
MOCK_DOCUMENTS,
|
import type { TemplateDocument } from './types';
|
||||||
DEFAULT_DOCUMENTS,
|
|
||||||
MOCK_DAY1_CATEGORIES,
|
|
||||||
MOCK_DAY1_CHECK_ITEMS,
|
|
||||||
MOCK_DAY1_STANDARD_DOCUMENTS,
|
|
||||||
} from './mockData';
|
|
||||||
|
|
||||||
// 기본 설정값
|
// 기본 설정값
|
||||||
const DEFAULT_SETTINGS: AuditDisplaySettings = {
|
const DEFAULT_SETTINGS: AuditDisplaySettings = {
|
||||||
@@ -41,195 +40,127 @@ export default function QualityInspectionPage() {
|
|||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
const [displaySettings, setDisplaySettings] = useState<AuditDisplaySettings>(DEFAULT_SETTINGS);
|
const [displaySettings, setDisplaySettings] = useState<AuditDisplaySettings>(DEFAULT_SETTINGS);
|
||||||
|
|
||||||
// 1일차 상태
|
// 1일차 커스텀 훅
|
||||||
const [day1Categories, setDay1Categories] = useState<ChecklistCategory[]>(MOCK_DAY1_CATEGORIES);
|
const {
|
||||||
const [selectedSubItemId, setSelectedSubItemId] = useState<string | null>(null);
|
templateId,
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null);
|
categories,
|
||||||
const [selectedStandardDocId, setSelectedStandardDocId] = useState<string | null>(null);
|
day1Progress,
|
||||||
|
selectedCheckItem,
|
||||||
|
isSelectedItemCompleted,
|
||||||
|
selectedSubItemId,
|
||||||
|
handleSubItemSelect,
|
||||||
|
handleSubItemToggle,
|
||||||
|
handleConfirmComplete,
|
||||||
|
isMock: day1IsMock,
|
||||||
|
} = useDay1Audit();
|
||||||
|
|
||||||
// 2일차(로트추적) 필터 상태
|
// 점검표 템플릿 관리 훅 (설정 모달용)
|
||||||
const [selectedYear, setSelectedYear] = useState(2025);
|
const checklistTemplate = useChecklistTemplate();
|
||||||
const [selectedQuarter, setSelectedQuarter] = useState<'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체'>('전체');
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
|
|
||||||
// 2일차 선택 상태
|
// 업로드된 파일 상태 (subItemId별)
|
||||||
const [selectedReport, setSelectedReport] = useState<InspectionReport | null>(null);
|
const [uploadedFiles, setUploadedFiles] = useState<Record<string, TemplateDocument[]>>({});
|
||||||
const [selectedRoute, setSelectedRoute] = useState<RouteItem | null>(null);
|
const [selectedUploadedFile, setSelectedUploadedFile] = useState<TemplateDocument | null>(null);
|
||||||
|
|
||||||
// 2일차 루트 데이터 상태 (완료 토글용)
|
// 선택된 항목 변경 시 파일 목록 로드 + 업로드 파일 선택 초기화
|
||||||
const [routesData, setRoutesData] = useState<Record<string, RouteItem[]>>(MOCK_ROUTES_INITIAL);
|
useEffect(() => {
|
||||||
|
setSelectedUploadedFile(null);
|
||||||
|
if (!selectedSubItemId || !templateId) return;
|
||||||
|
if (uploadedFiles[selectedSubItemId]) return; // 이미 로드됨
|
||||||
|
|
||||||
// 모달 상태
|
getTemplateDocuments(templateId, selectedSubItemId).then((result) => {
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
if (result.success && result.data) {
|
||||||
const [selectedDoc, setSelectedDoc] = useState<Document | null>(null);
|
setUploadedFiles((prev) => ({ ...prev, [selectedSubItemId]: result.data! }));
|
||||||
const [selectedDocItem, setSelectedDocItem] = useState<DocumentItem | null>(null);
|
}
|
||||||
|
|
||||||
// ===== 1일차 진행률 계산 =====
|
|
||||||
const day1Progress = useMemo(() => {
|
|
||||||
const total = day1Categories.reduce((sum, cat) => sum + cat.subItems.length, 0);
|
|
||||||
const completed = day1Categories.reduce(
|
|
||||||
(sum, cat) => sum + cat.subItems.filter(item => item.isCompleted).length,
|
|
||||||
0
|
|
||||||
);
|
|
||||||
return { completed, total };
|
|
||||||
}, [day1Categories]);
|
|
||||||
|
|
||||||
// ===== 2일차 진행률 계산 (개소별 완료 기준) =====
|
|
||||||
const day2Progress = useMemo(() => {
|
|
||||||
let completed = 0;
|
|
||||||
let total = 0;
|
|
||||||
Object.values(routesData).forEach(routes => {
|
|
||||||
routes.forEach(route => {
|
|
||||||
route.subItems.forEach(item => {
|
|
||||||
total++;
|
|
||||||
if (item.isCompleted) completed++;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return { completed, total };
|
}, [selectedSubItemId, templateId]);
|
||||||
}, [routesData]);
|
|
||||||
|
|
||||||
// ===== 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션) =====
|
// 파일 업로드 핸들러
|
||||||
|
const handleFileUpload = useCallback(async (subItemId: string, file: File): Promise<boolean> => {
|
||||||
|
if (!templateId) {
|
||||||
|
toast.error('점검표 템플릿이 로드되지 않았습니다.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = await uploadTemplateDocument(templateId, subItemId, file);
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error || '파일 업로드에 실패했습니다.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 업로드 성공 시 파일 목록에 추가
|
||||||
|
if (result.data) {
|
||||||
|
setUploadedFiles((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[subItemId]: [result.data!, ...(prev[subItemId] || [])],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [templateId]);
|
||||||
|
|
||||||
|
// 파일 삭제 핸들러
|
||||||
|
const handleFileDelete = useCallback(async (fileId: number, subItemId: string) => {
|
||||||
|
const result = await deleteTemplateDocument(fileId);
|
||||||
|
if (result.success) {
|
||||||
|
setUploadedFiles((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[subItemId]: (prev[subItemId] || []).filter((f) => f.id !== fileId),
|
||||||
|
}));
|
||||||
|
toast.success('파일이 삭제되었습니다.');
|
||||||
|
} else {
|
||||||
|
toast.error(result.error || '파일 삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 2일차 커스텀 훅
|
||||||
|
const {
|
||||||
|
selectedYear,
|
||||||
|
selectedQuarter,
|
||||||
|
searchTerm,
|
||||||
|
handleYearChange,
|
||||||
|
handleQuarterChange,
|
||||||
|
handleSearchChange,
|
||||||
|
filteredReports,
|
||||||
|
currentRoutes,
|
||||||
|
currentDocuments,
|
||||||
|
day2Progress,
|
||||||
|
selectedReport,
|
||||||
|
selectedRoute,
|
||||||
|
handleReportSelect,
|
||||||
|
handleRouteSelect,
|
||||||
|
modalOpen,
|
||||||
|
selectedDoc,
|
||||||
|
selectedDocItem,
|
||||||
|
handleViewDocument,
|
||||||
|
setModalOpen,
|
||||||
|
handleToggleItem,
|
||||||
|
updateQualityDocumentFile,
|
||||||
|
isMock: day2IsMock,
|
||||||
|
} = useDay2LotAudit();
|
||||||
|
|
||||||
|
// 품질관리서 파일 업로드 핸들러 (2일차 DocumentList용)
|
||||||
|
const handleQualityFileUpload = useCallback(async (qualityDocumentId: string, file: File): Promise<boolean> => {
|
||||||
|
const result = await uploadQualityDocumentFile(qualityDocumentId, file);
|
||||||
|
if (!result.success) {
|
||||||
|
toast.error(result.error || '품질관리서 파일 업로드에 실패했습니다.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 업로드 성공 시 documents 상태에서 해당 문서의 파일 정보 업데이트
|
||||||
|
if (result.data) {
|
||||||
|
updateQualityDocumentFile(qualityDocumentId, result.data);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}, [updateQualityDocumentFile]);
|
||||||
|
|
||||||
|
// 1일차 필터링된 카테고리 (완료 항목 숨기기 옵션)
|
||||||
const filteredDay1Categories = useMemo(() => {
|
const filteredDay1Categories = useMemo(() => {
|
||||||
if (displaySettings.showCompletedItems) return day1Categories;
|
if (displaySettings.showCompletedItems) return categories;
|
||||||
|
|
||||||
return day1Categories.map(category => ({
|
return categories.map(category => ({
|
||||||
...category,
|
...category,
|
||||||
subItems: category.subItems.filter(item => !item.isCompleted),
|
subItems: category.subItems.filter(item => !item.isCompleted),
|
||||||
})).filter(category => category.subItems.length > 0);
|
})).filter(category => category.subItems.length > 0);
|
||||||
}, [day1Categories, displaySettings.showCompletedItems]);
|
}, [categories, displaySettings.showCompletedItems]);
|
||||||
|
|
||||||
// ===== 1일차 핸들러 =====
|
|
||||||
const handleSubItemSelect = useCallback((categoryId: string, subItemId: string) => {
|
|
||||||
setSelectedCategoryId(categoryId);
|
|
||||||
setSelectedSubItemId(subItemId);
|
|
||||||
setSelectedStandardDocId(null);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSubItemToggle = useCallback((categoryId: string, subItemId: string, isCompleted: boolean) => {
|
|
||||||
setDay1Categories(prev => prev.map(cat => {
|
|
||||||
if (cat.id !== categoryId) return cat;
|
|
||||||
return {
|
|
||||||
...cat,
|
|
||||||
subItems: cat.subItems.map(item => {
|
|
||||||
if (item.id !== subItemId) return item;
|
|
||||||
return { ...item, isCompleted };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleConfirmComplete = useCallback(() => {
|
|
||||||
if (selectedCategoryId && selectedSubItemId) {
|
|
||||||
handleSubItemToggle(selectedCategoryId, selectedSubItemId, true);
|
|
||||||
}
|
|
||||||
}, [selectedCategoryId, selectedSubItemId, handleSubItemToggle]);
|
|
||||||
|
|
||||||
// 선택된 1일차 점검 항목
|
|
||||||
const selectedCheckItem = useMemo(() => {
|
|
||||||
if (!selectedSubItemId) return null;
|
|
||||||
return MOCK_DAY1_CHECK_ITEMS.find(item => item.subItemId === selectedSubItemId) || null;
|
|
||||||
}, [selectedSubItemId]);
|
|
||||||
|
|
||||||
// 선택된 표준 문서
|
|
||||||
const selectedStandardDoc = useMemo(() => {
|
|
||||||
if (!selectedStandardDocId || !selectedSubItemId) return null;
|
|
||||||
const docs = MOCK_DAY1_STANDARD_DOCUMENTS[selectedSubItemId] || [];
|
|
||||||
return docs.find(doc => doc.id === selectedStandardDocId) || null;
|
|
||||||
}, [selectedStandardDocId, selectedSubItemId]);
|
|
||||||
|
|
||||||
// 선택된 항목의 완료 여부
|
|
||||||
const isSelectedItemCompleted = useMemo(() => {
|
|
||||||
if (!selectedSubItemId) return false;
|
|
||||||
for (const cat of day1Categories) {
|
|
||||||
const item = cat.subItems.find(item => item.id === selectedSubItemId);
|
|
||||||
if (item) return item.isCompleted;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}, [day1Categories, selectedSubItemId]);
|
|
||||||
|
|
||||||
// ===== 2일차(로트추적) 로직 =====
|
|
||||||
const filteredReports = useMemo(() => {
|
|
||||||
return MOCK_REPORTS.filter((report) => {
|
|
||||||
if (report.year !== selectedYear) return false;
|
|
||||||
if (selectedQuarter !== '전체') {
|
|
||||||
const quarterNum = parseInt(selectedQuarter.replace('Q', ''));
|
|
||||||
if (report.quarterNum !== quarterNum) return false;
|
|
||||||
}
|
|
||||||
if (searchTerm) {
|
|
||||||
const term = searchTerm.toLowerCase();
|
|
||||||
const matchesCode = report.code.toLowerCase().includes(term);
|
|
||||||
const matchesSite = report.siteName.toLowerCase().includes(term);
|
|
||||||
const matchesItem = report.item.toLowerCase().includes(term);
|
|
||||||
if (!matchesCode && !matchesSite && !matchesItem) return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}, [selectedYear, selectedQuarter, searchTerm]);
|
|
||||||
|
|
||||||
const currentRoutes = useMemo(() => {
|
|
||||||
if (!selectedReport) return [];
|
|
||||||
return routesData[selectedReport.id] || [];
|
|
||||||
}, [selectedReport, routesData]);
|
|
||||||
|
|
||||||
const currentDocuments = useMemo(() => {
|
|
||||||
if (!selectedRoute) return DEFAULT_DOCUMENTS;
|
|
||||||
return MOCK_DOCUMENTS[selectedRoute.id] || DEFAULT_DOCUMENTS;
|
|
||||||
}, [selectedRoute]);
|
|
||||||
|
|
||||||
const handleReportSelect = (report: InspectionReport) => {
|
|
||||||
setSelectedReport(report);
|
|
||||||
setSelectedRoute(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRouteSelect = (route: RouteItem) => {
|
|
||||||
setSelectedRoute(route);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleViewDocument = (doc: Document, item?: DocumentItem) => {
|
|
||||||
setSelectedDoc(doc);
|
|
||||||
setSelectedDocItem(item || null);
|
|
||||||
setModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleYearChange = (year: number) => {
|
|
||||||
setSelectedYear(year);
|
|
||||||
setSelectedReport(null);
|
|
||||||
setSelectedRoute(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleQuarterChange = (quarter: 'Q1' | 'Q2' | 'Q3' | 'Q4' | '전체') => {
|
|
||||||
setSelectedQuarter(quarter);
|
|
||||||
setSelectedReport(null);
|
|
||||||
setSelectedRoute(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = (term: string) => {
|
|
||||||
setSearchTerm(term);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ===== 2일차 개소별 완료 토글 =====
|
|
||||||
const handleToggleItem = useCallback((routeId: string, itemId: string, isCompleted: boolean) => {
|
|
||||||
setRoutesData(prev => {
|
|
||||||
const newData = { ...prev };
|
|
||||||
for (const reportId of Object.keys(newData)) {
|
|
||||||
newData[reportId] = newData[reportId].map(route => {
|
|
||||||
if (route.id !== routeId) return route;
|
|
||||||
return {
|
|
||||||
...route,
|
|
||||||
subItems: route.subItems.map(item => {
|
|
||||||
if (item.id !== itemId) return item;
|
|
||||||
return { ...item, isCompleted };
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return newData;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-hidden">
|
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] p-3 sm:p-4 md:p-5 lg:p-6 bg-slate-100 flex flex-col lg:overflow-auto">
|
||||||
{/* 헤더 (설정 버튼 포함) */}
|
{/* 헤더 (설정 버튼 포함) */}
|
||||||
<Header
|
<Header
|
||||||
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
|
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
|
||||||
@@ -283,9 +214,9 @@ export default function QualityInspectionPage() {
|
|||||||
|
|
||||||
{activeDay === 1 ? (
|
{activeDay === 1 ? (
|
||||||
// ===== 기준/매뉴얼 심사 심사 =====
|
// ===== 기준/매뉴얼 심사 심사 =====
|
||||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
|
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-[500px]">
|
||||||
{/* 좌측: 점검표 항목 */}
|
{/* 좌측: 점검표 항목 */}
|
||||||
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto ${
|
<div className={`col-span-12 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||||
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
|
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
|
||||||
? 'lg:col-span-3'
|
? 'lg:col-span-3'
|
||||||
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
|
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
|
||||||
@@ -298,59 +229,70 @@ export default function QualityInspectionPage() {
|
|||||||
searchTerm={searchTerm}
|
searchTerm={searchTerm}
|
||||||
onSubItemSelect={handleSubItemSelect}
|
onSubItemSelect={handleSubItemSelect}
|
||||||
onSubItemToggle={handleSubItemToggle}
|
onSubItemToggle={handleSubItemToggle}
|
||||||
|
isMock={day1IsMock}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: 기준 문서화 */}
|
{/* 중앙: 기준 문서화 */}
|
||||||
{displaySettings.showDocumentSection && (
|
{displaySettings.showDocumentSection && (
|
||||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
|
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||||
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
|
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
|
||||||
}`}>
|
}`}>
|
||||||
<Day1DocumentSection
|
<Day1DocumentSection
|
||||||
checkItem={selectedCheckItem}
|
checkItem={selectedCheckItem}
|
||||||
selectedDocumentId={selectedStandardDocId}
|
|
||||||
onDocumentSelect={setSelectedStandardDocId}
|
|
||||||
onConfirmComplete={handleConfirmComplete}
|
onConfirmComplete={handleConfirmComplete}
|
||||||
isCompleted={isSelectedItemCompleted}
|
isCompleted={isSelectedItemCompleted}
|
||||||
|
isMock={day1IsMock}
|
||||||
|
onFileUpload={handleFileUpload}
|
||||||
|
uploadedFiles={selectedSubItemId ? uploadedFiles[selectedSubItemId] || [] : []}
|
||||||
|
onFileDelete={selectedSubItemId ? (fileId) => handleFileDelete(fileId, selectedSubItemId) : undefined}
|
||||||
|
onFileSelect={(file) => {
|
||||||
|
setSelectedUploadedFile(file);
|
||||||
|
}}
|
||||||
|
selectedFileId={selectedUploadedFile?.id ?? null}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 우측: 문서 뷰어 */}
|
{/* 우측: 문서 뷰어 */}
|
||||||
{displaySettings.showDocumentViewer && (
|
{displaySettings.showDocumentViewer && (
|
||||||
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto ${
|
<div className={`col-span-12 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto ${
|
||||||
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
|
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
|
||||||
}`}>
|
}`}>
|
||||||
<Day1DocumentViewer document={selectedStandardDoc} />
|
<Day1DocumentViewer document={null} uploadedFile={selectedUploadedFile} isMock={day1IsMock} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// ===== 로트 추적 심사 심사 =====
|
// ===== 로트 추적 심사 =====
|
||||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
|
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-[500px]">
|
||||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
<div className="col-span-12 lg:col-span-4 min-h-[250px] sm:min-h-[300px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||||
<ReportList
|
<ReportList
|
||||||
reports={filteredReports}
|
reports={filteredReports}
|
||||||
selectedId={selectedReport?.id || null}
|
selectedId={selectedReport?.id || null}
|
||||||
onSelect={handleReportSelect}
|
onSelect={handleReportSelect}
|
||||||
|
isMock={day2IsMock}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||||
<RouteList
|
<RouteList
|
||||||
routes={currentRoutes}
|
routes={currentRoutes}
|
||||||
selectedId={selectedRoute?.id || null}
|
selectedId={selectedRoute?.id || null}
|
||||||
onSelect={handleRouteSelect}
|
onSelect={handleRouteSelect}
|
||||||
onToggleItem={handleToggleItem}
|
onToggleItem={handleToggleItem}
|
||||||
reportCode={selectedReport?.code || null}
|
reportCode={selectedReport?.code || null}
|
||||||
|
isMock={day2IsMock}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
<div className="col-span-12 lg:col-span-4 min-h-[200px] sm:min-h-[250px] lg:min-h-[500px] lg:h-full overflow-auto">
|
||||||
<DocumentList
|
<DocumentList
|
||||||
documents={currentDocuments}
|
documents={currentDocuments}
|
||||||
routeCode={selectedRoute?.code || null}
|
routeCode={selectedRoute?.code || null}
|
||||||
onViewDocument={handleViewDocument}
|
onViewDocument={handleViewDocument}
|
||||||
|
onQualityFileUpload={handleQualityFileUpload}
|
||||||
|
isMock={day2IsMock}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,15 +304,72 @@ export default function QualityInspectionPage() {
|
|||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
settings={displaySettings}
|
settings={displaySettings}
|
||||||
onSettingsChange={setDisplaySettings}
|
onSettingsChange={setDisplaySettings}
|
||||||
|
checklistManagement={{
|
||||||
|
categories: checklistTemplate.editCategories,
|
||||||
|
hasChanges: checklistTemplate.hasChanges,
|
||||||
|
saving: checklistTemplate.saving,
|
||||||
|
loading: checklistTemplate.loading,
|
||||||
|
error: checklistTemplate.error,
|
||||||
|
onAddCategory: checklistTemplate.addCategory,
|
||||||
|
onUpdateCategoryTitle: checklistTemplate.updateCategoryTitle,
|
||||||
|
onDeleteCategory: checklistTemplate.deleteCategory,
|
||||||
|
onMoveCategoryUp: checklistTemplate.moveCategoryUp,
|
||||||
|
onMoveCategoryDown: checklistTemplate.moveCategoryDown,
|
||||||
|
onAddSubItem: checklistTemplate.addSubItem,
|
||||||
|
onUpdateSubItemName: checklistTemplate.updateSubItemName,
|
||||||
|
onDeleteSubItem: checklistTemplate.deleteSubItem,
|
||||||
|
onMoveSubItemUp: checklistTemplate.moveSubItemUp,
|
||||||
|
onMoveSubItemDown: checklistTemplate.moveSubItemDown,
|
||||||
|
onSave: checklistTemplate.saveTemplate,
|
||||||
|
onReset: checklistTemplate.resetToSaved,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InspectionModal
|
{/* 중간검사 성적서 → 기존 독립 모달 재사용 */}
|
||||||
isOpen={modalOpen}
|
{selectedDoc?.type === 'report' && (
|
||||||
onClose={() => setModalOpen(false)}
|
<InspectionReportModal
|
||||||
document={selectedDoc}
|
open={modalOpen}
|
||||||
documentItem={selectedDocItem}
|
onOpenChange={(open) => !open && setModalOpen(false)}
|
||||||
readOnly
|
workOrderId={selectedDocItem?.workOrderId ? String(selectedDocItem.workOrderId) : selectedDocItem?.id || null}
|
||||||
/>
|
processType={
|
||||||
|
selectedDocItem?.subType === 'jointbar' ? 'slat'
|
||||||
|
: (selectedDocItem?.subType as 'screen' | 'slat' | 'bending') || 'screen'
|
||||||
|
}
|
||||||
|
readOnly
|
||||||
|
isJointBar={selectedDocItem?.subType === 'jointbar'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 작업일지 → 독립 WorkLogModal */}
|
||||||
|
{selectedDoc?.type === 'log' && (
|
||||||
|
<WorkLogModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={(open) => !open && setModalOpen(false)}
|
||||||
|
workOrderId={selectedDocItem?.workOrderId ? String(selectedDocItem.workOrderId) : selectedDocItem?.id || null}
|
||||||
|
processType={(selectedDocItem?.subType as 'screen' | 'slat' | 'bending') || 'screen'}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 제품검사 성적서 → 독립 ProductInspectionViewModal */}
|
||||||
|
{selectedDoc?.type === 'product' && (
|
||||||
|
<ProductInspectionViewModal
|
||||||
|
open={modalOpen}
|
||||||
|
onOpenChange={(open) => !open && setModalOpen(false)}
|
||||||
|
locationId={selectedDocItem?.id || null}
|
||||||
|
fetchDetail={getDocumentDetail}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 나머지 문서 타입 (수입검사, 수주서, 납품확인서, 출고증) → 기존 InspectionModal */}
|
||||||
|
{selectedDoc && !['report', 'log', 'product', 'quality'].includes(selectedDoc.type) && (
|
||||||
|
<InspectionModal
|
||||||
|
isOpen={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
document={selectedDoc}
|
||||||
|
documentItem={selectedDocItem}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface RouteItem {
|
|||||||
id: string;
|
id: string;
|
||||||
code: string; // e.g., KD-SS-240924-19
|
code: string; // e.g., KD-SS-240924-19
|
||||||
date: string; // 2024-09-24
|
date: string; // 2024-09-24
|
||||||
|
client: string; // 거래처(발주처)
|
||||||
site: string; // 강남 아파트 A동
|
site: string; // 강남 아파트 A동
|
||||||
locationCount: number;
|
locationCount: number;
|
||||||
subItems: UnitInspection[];
|
subItems: UnitInspection[];
|
||||||
@@ -33,6 +34,9 @@ export interface Document {
|
|||||||
date?: string;
|
date?: string;
|
||||||
count: number; // e.g., 3건의 서류
|
count: number; // e.g., 3건의 서류
|
||||||
items?: DocumentItem[];
|
items?: DocumentItem[];
|
||||||
|
fileId?: number; // files.id (품질관리서 파일)
|
||||||
|
fileName?: string; // 파일명
|
||||||
|
fileSize?: number; // 파일 크기 (bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentItem {
|
export interface DocumentItem {
|
||||||
@@ -42,6 +46,8 @@ export interface DocumentItem {
|
|||||||
code?: string;
|
code?: string;
|
||||||
// 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용)
|
// 중간검사 성적서 및 작업일지 서브타입 (report, log 타입에서 사용)
|
||||||
subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
|
subType?: 'screen' | 'bending' | 'slat' | 'jointbar';
|
||||||
|
// 작업일지/중간검사에서 실제 WorkOrder 데이터 로딩용
|
||||||
|
workOrderId?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 기준/매뉴얼 심사 심사 타입 =====
|
// ===== 기준/매뉴얼 심사 심사 타입 =====
|
||||||
@@ -92,3 +98,28 @@ export interface Day2Progress {
|
|||||||
completed: number;
|
completed: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 업로드된 템플릿 문서
|
||||||
|
export interface TemplateDocument {
|
||||||
|
id: number;
|
||||||
|
fieldKey: string;
|
||||||
|
displayName: string;
|
||||||
|
fileSize: number;
|
||||||
|
mimeType: string;
|
||||||
|
uploadedBy?: string | null;
|
||||||
|
createdAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 점검표 템플릿 관리 타입 =====
|
||||||
|
|
||||||
|
// 점검표 템플릿 (API 응답)
|
||||||
|
export interface ChecklistTemplate {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
categories: ChecklistCategory[];
|
||||||
|
options: Record<string, unknown> | null;
|
||||||
|
fileCounts: Record<string, number>;
|
||||||
|
updatedAt: string | null;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user