fix(WEB): 토큰 만료 시 무한 로딩 대신 로그인 리다이렉트 처리
- 52개 이상의 컴포넌트에 isNextRedirectError 처리 추가 - Server Action의 redirect() 에러가 catch 블록에서 삼켜지는 문제 해결 - access_token + refresh_token 모두 만료 시 정상적으로 로그인 페이지로 리다이렉트 수정된 영역: - accounting: 10개 컴포넌트 - production: 12개 컴포넌트 - hr: 5개 컴포넌트 - settings: 8개 컴포넌트 - approval: 5개 컴포넌트 - items: 20개+ 컴포넌트 - board: 5개 컴포넌트 - quality: 4개 컴포넌트 - material, outbound, quotes 등 기타 컴포넌트 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
331
claudedocs/[PLAN] ceo-dashboard-refactoring.md
Normal file
331
claudedocs/[PLAN] ceo-dashboard-refactoring.md
Normal file
@@ -0,0 +1,331 @@
|
||||
# CEO 대시보드 리팩토링 계획
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 대상 파일: `src/components/business/CEODashboard/`
|
||||
> 목표: 파일 분리 + 모바일(344px) 대응
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 상태 분석
|
||||
|
||||
### 1.1 파일 구조
|
||||
|
||||
```
|
||||
CEODashboard/
|
||||
├── CEODashboard.tsx # 1,648줄 ⚠️ 분리 필요
|
||||
├── components.tsx # 312줄 ✅ 적정
|
||||
├── types.ts # ~100줄 ✅ 적정
|
||||
├── sections/
|
||||
│ ├── index.ts
|
||||
│ ├── TodayIssueSection.tsx # 73줄 ✅
|
||||
│ ├── DailyReportSection.tsx # 37줄 ✅
|
||||
│ ├── MonthlyExpenseSection.tsx # 38줄 ✅
|
||||
│ ├── CardManagementSection.tsx # ~50줄 ✅
|
||||
│ ├── EntertainmentSection.tsx # ~50줄 ✅
|
||||
│ ├── WelfareSection.tsx # ~50줄 ✅
|
||||
│ ├── ReceivableSection.tsx # ~50줄 ✅
|
||||
│ ├── DebtCollectionSection.tsx # ~50줄 ✅
|
||||
│ ├── VatSection.tsx # ~50줄 ✅
|
||||
│ └── CalendarSection.tsx # ~100줄 ✅
|
||||
├── modals/
|
||||
│ ├── ScheduleDetailModal.tsx # ~200줄 ✅
|
||||
│ └── DetailModal.tsx # ~300줄 ✅
|
||||
└── dialogs/
|
||||
└── DashboardSettingsDialog.tsx # ~200줄 ✅
|
||||
```
|
||||
|
||||
### 1.2 CEODashboard.tsx 내부 분석 (1,648줄)
|
||||
|
||||
| 줄 범위 | 내용 | 줄 수 | 분리 대상 |
|
||||
|---------|------|-------|----------|
|
||||
| 1-26 | imports | 26 | - |
|
||||
| 27-370 | mockData 객체 | **344** | ✅ 분리 |
|
||||
| 371-748 | handleMonthlyExpenseCardClick (모달 config) | **378** | ✅ 분리 |
|
||||
| 749-1019 | handleCardManagementCardClick (모달 config) | **271** | ✅ 분리 |
|
||||
| 1020-1247 | handleEntertainmentCardClick (모달 config) | **228** | ✅ 분리 |
|
||||
| 1248-1375 | handleWelfareCardClick (모달 config) | **128** | ✅ 분리 |
|
||||
| 1376-1465 | handleVatClick (모달 config) | **90** | ✅ 분리 |
|
||||
| 1466-1509 | 캘린더 관련 핸들러 | 44 | - |
|
||||
| 1510-1648 | 컴포넌트 렌더링 | 139 | - |
|
||||
|
||||
**분리 대상 총합**: ~1,439줄 (87%)
|
||||
**분리 후 예상**: ~210줄
|
||||
|
||||
---
|
||||
|
||||
## 2. 분리 계획
|
||||
|
||||
### 2.1 목표 구조
|
||||
|
||||
```
|
||||
CEODashboard/
|
||||
├── CEODashboard.tsx # ~250줄 (컴포넌트 + 핸들러)
|
||||
├── components.tsx # 312줄 (유지)
|
||||
├── types.ts # ~100줄 (유지)
|
||||
├── mockData.ts # 🆕 ~350줄 (목데이터)
|
||||
├── modalConfigs/ # 🆕 모달 설정 분리
|
||||
│ ├── index.ts
|
||||
│ ├── monthlyExpenseConfigs.ts # ~380줄
|
||||
│ ├── cardManagementConfigs.ts # ~280줄
|
||||
│ ├── entertainmentConfigs.ts # ~230줄
|
||||
│ ├── welfareConfigs.ts # ~130줄
|
||||
│ └── vatConfigs.ts # ~100줄
|
||||
├── sections/ # (유지)
|
||||
├── modals/ # (유지)
|
||||
└── dialogs/ # (유지)
|
||||
```
|
||||
|
||||
### 2.2 분리 파일 상세
|
||||
|
||||
#### A. mockData.ts (신규)
|
||||
|
||||
```typescript
|
||||
// mockData.ts
|
||||
import type { CEODashboardData } from './types';
|
||||
|
||||
export const mockData: CEODashboardData = {
|
||||
todayIssue: [...],
|
||||
dailyReport: {...},
|
||||
monthlyExpense: {...},
|
||||
cardManagement: {...},
|
||||
entertainment: {...},
|
||||
welfare: {...},
|
||||
receivable: {...},
|
||||
debtCollection: {...},
|
||||
vat: {...},
|
||||
calendarSchedules: [...],
|
||||
};
|
||||
```
|
||||
|
||||
#### B. modalConfigs/index.ts (신규)
|
||||
|
||||
```typescript
|
||||
// modalConfigs/index.ts
|
||||
export { getMonthlyExpenseModalConfig } from './monthlyExpenseConfigs';
|
||||
export { getCardManagementModalConfig } from './cardManagementConfigs';
|
||||
export { getEntertainmentModalConfig } from './entertainmentConfigs';
|
||||
export { getWelfareModalConfig } from './welfareConfigs';
|
||||
export { getVatModalConfig } from './vatConfigs';
|
||||
```
|
||||
|
||||
#### C. 개별 모달 config 파일 예시
|
||||
|
||||
```typescript
|
||||
// modalConfigs/monthlyExpenseConfigs.ts
|
||||
import type { DetailModalConfig } from '../types';
|
||||
|
||||
export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null {
|
||||
const configs: Record<string, DetailModalConfig> = {
|
||||
me1: { title: '당월 매입 상세', ... },
|
||||
me2: { title: '당월 카드 상세', ... },
|
||||
me3: { title: '당월 발행어음 상세', ... },
|
||||
me4: { title: '당월 지출 예상 상세', ... },
|
||||
};
|
||||
return configs[cardId] || null;
|
||||
}
|
||||
```
|
||||
|
||||
#### D. CEODashboard.tsx (리팩토링 후)
|
||||
|
||||
```typescript
|
||||
// CEODashboard.tsx (리팩토링 후 ~250줄)
|
||||
import { mockData } from './mockData';
|
||||
import {
|
||||
getMonthlyExpenseModalConfig,
|
||||
getCardManagementModalConfig,
|
||||
getEntertainmentModalConfig,
|
||||
getWelfareModalConfig,
|
||||
getVatModalConfig,
|
||||
} from './modalConfigs';
|
||||
|
||||
export function CEODashboard() {
|
||||
// 상태 관리
|
||||
const [data] = useState<CEODashboardData>(mockData);
|
||||
const [detailModalConfig, setDetailModalConfig] = useState<DetailModalConfig | null>(null);
|
||||
// ...
|
||||
|
||||
// 간소화된 핸들러
|
||||
const handleMonthlyExpenseCardClick = useCallback((cardId: string) => {
|
||||
const config = getMonthlyExpenseModalConfig(cardId);
|
||||
if (config) {
|
||||
setDetailModalConfig(config);
|
||||
setIsDetailModalOpen(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 렌더링
|
||||
return (...);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 모바일 대응 계획
|
||||
|
||||
### 3.1 적용 대상 컴포넌트
|
||||
|
||||
| 컴포넌트 | 현재 상태 | 변경 필요 |
|
||||
|----------|----------|----------|
|
||||
| TodayIssueSection | `grid-cols-2 md:grid-cols-4` | ✅ `grid-cols-1 xs:grid-cols-2 md:grid-cols-4` |
|
||||
| DailyReportSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 |
|
||||
| MonthlyExpenseSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 |
|
||||
| CardManagementSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 |
|
||||
| EntertainmentSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 |
|
||||
| WelfareSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 |
|
||||
| ReceivableSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 |
|
||||
| DebtCollectionSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 |
|
||||
| VatSection | `grid-cols-2 md:grid-cols-4` | ✅ 동일 |
|
||||
| AmountCardItem (공통) | 고정 텍스트 크기 | ✅ 반응형 텍스트 |
|
||||
| IssueCardItem (공통) | 고정 텍스트 크기 | ✅ 반응형 텍스트 |
|
||||
| PageHeader | 가로 배치 | ✅ 세로/가로 반응형 |
|
||||
|
||||
### 3.2 components.tsx 변경 사항
|
||||
|
||||
#### AmountCardItem
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<p className="text-2xl md:text-3xl font-bold">
|
||||
{formatCardAmount(card.amount)}
|
||||
</p>
|
||||
|
||||
// After
|
||||
<p className="text-lg xs:text-xl md:text-2xl lg:text-3xl font-bold truncate">
|
||||
{formatCardAmount(card.amount)}
|
||||
</p>
|
||||
<p className="text-xs xs:text-sm font-medium mb-1 xs:mb-2 break-keep">
|
||||
{card.label}
|
||||
</p>
|
||||
```
|
||||
|
||||
#### IssueCardItem
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<p className="text-2xl md:text-3xl font-bold">
|
||||
{typeof count === 'number' ? `${count}건` : count}
|
||||
</p>
|
||||
|
||||
// After
|
||||
<p className="text-lg xs:text-xl md:text-2xl lg:text-3xl font-bold">
|
||||
{typeof count === 'number' ? `${count}건` : count}
|
||||
</p>
|
||||
```
|
||||
|
||||
### 3.3 섹션 공통 변경
|
||||
|
||||
```tsx
|
||||
// Before (모든 섹션)
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
|
||||
// After
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4">
|
||||
```
|
||||
|
||||
### 3.4 CardContent 패딩
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<CardContent className="p-6">
|
||||
|
||||
// After
|
||||
<CardContent className="p-3 xs:p-4 md:p-6">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 실행 계획
|
||||
|
||||
### Phase 1: 파일 분리 (예상 30분)
|
||||
|
||||
- [ ] **1.1** `mockData.ts` 생성 및 데이터 이동
|
||||
- [ ] **1.2** `modalConfigs/` 폴더 생성
|
||||
- [ ] **1.3** `monthlyExpenseConfigs.ts` 생성
|
||||
- [ ] **1.4** `cardManagementConfigs.ts` 생성
|
||||
- [ ] **1.5** `entertainmentConfigs.ts` 생성
|
||||
- [ ] **1.6** `welfareConfigs.ts` 생성
|
||||
- [ ] **1.7** `vatConfigs.ts` 생성
|
||||
- [ ] **1.8** `modalConfigs/index.ts` 생성
|
||||
- [ ] **1.9** `CEODashboard.tsx` 리팩토링
|
||||
- [ ] **1.10** import 정리 및 동작 확인
|
||||
|
||||
### Phase 2: 모바일 대응 (예상 30분)
|
||||
|
||||
- [ ] **2.1** `components.tsx` - AmountCardItem 반응형 적용
|
||||
- [ ] **2.2** `components.tsx` - IssueCardItem 반응형 적용
|
||||
- [ ] **2.3** `sections/*.tsx` - 그리드 반응형 적용 (일괄)
|
||||
- [ ] **2.4** `sections/*.tsx` - CardContent 패딩 반응형 적용
|
||||
- [ ] **2.5** PageHeader 반응형 확인
|
||||
- [ ] **2.6** 344px 테스트 및 미세 조정
|
||||
|
||||
### Phase 3: 검증 (예상 15분)
|
||||
|
||||
- [ ] **3.1** 빌드 확인 요청
|
||||
- [ ] **3.2** 데스크탑(1280px) 동작 확인
|
||||
- [ ] **3.3** 태블릿(768px) 동작 확인
|
||||
- [ ] **3.4** 모바일(375px) 동작 확인
|
||||
- [ ] **3.5** Galaxy Fold(344px) 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## 5. 예상 결과
|
||||
|
||||
### 5.1 파일 크기 변화
|
||||
|
||||
| 파일 | Before | After |
|
||||
|------|--------|-------|
|
||||
| CEODashboard.tsx | 1,648줄 | ~250줄 |
|
||||
| mockData.ts | - | ~350줄 |
|
||||
| modalConfigs/*.ts | - | ~1,100줄 (5개 파일) |
|
||||
|
||||
### 5.2 장점
|
||||
|
||||
1. **유지보수성**: 각 파일이 단일 책임 원칙 준수
|
||||
2. **재사용성**: 모달 config를 다른 곳에서 재사용 가능
|
||||
3. **확장성**: 새 모달 추가 시 별도 파일로 분리
|
||||
4. **가독성**: 핵심 로직만 CEODashboard.tsx에 유지
|
||||
5. **API 전환 용이**: mockData.ts만 교체하면 됨
|
||||
|
||||
### 5.3 모바일 개선 효과
|
||||
|
||||
| 항목 | Before (344px) | After (344px) |
|
||||
|------|----------------|---------------|
|
||||
| 카드 배치 | 2열 (160px/카드) | 1열 (320px/카드) |
|
||||
| 금액 표시 | 잘림 가능 | 완전 표시 |
|
||||
| 라벨 표시 | 잘림 가능 | 줄바꿈/truncate |
|
||||
| 패딩 | 과다 (24px) | 적정 (12px) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 참고 문서
|
||||
|
||||
- **모바일 대응 가이드**: `claudedocs/guides/[GUIDE] mobile-responsive-patterns.md`
|
||||
- **기존 테스트 계획**: `claudedocs/[PLAN] mobile-overflow-testing.md`
|
||||
|
||||
---
|
||||
|
||||
## 7. 의사결정 사항
|
||||
|
||||
### Q1: mockData를 별도 파일로?
|
||||
- **결정**: ✅ 분리
|
||||
- **이유**: 향후 API 연동 시 교체 용이
|
||||
|
||||
### Q2: 모달 config를 폴더로?
|
||||
- **결정**: ✅ 폴더로 분리
|
||||
- **이유**: 각 config가 100줄 이상, 단일 파일은 여전히 큼
|
||||
|
||||
### Q3: 모바일에서 1열 vs 2열?
|
||||
- **결정**: 344px 이하 1열, 375px 이상 2열
|
||||
- **이유**: Galaxy Fold 160px 카드는 너무 좁음
|
||||
|
||||
---
|
||||
|
||||
## 8. 시작 조건
|
||||
|
||||
- [x] 계획서 작성 완료
|
||||
- [x] 모바일 가이드 작성 완료
|
||||
- [ ] 사용자 승인
|
||||
|
||||
---
|
||||
|
||||
> **다음 단계**: 계획 승인 후 Phase 1 (파일 분리) 시작
|
||||
538
claudedocs/guides/[GUIDE] mobile-responsive-patterns.md
Normal file
538
claudedocs/guides/[GUIDE] mobile-responsive-patterns.md
Normal file
@@ -0,0 +1,538 @@
|
||||
# 모바일 반응형 패턴 가이드
|
||||
|
||||
> 작성일: 2026-01-10
|
||||
> 적용 범위: SAM 프로젝트 전체
|
||||
> 주요 대상 기기: Galaxy Z Fold 5 (접힌 상태 344px)
|
||||
|
||||
---
|
||||
|
||||
## 1. 브레이크포인트 정의
|
||||
|
||||
### 1.1 Tailwind 기본 브레이크포인트
|
||||
|
||||
| 접두사 | 최소 너비 | 대상 기기 |
|
||||
|--------|----------|----------|
|
||||
| (기본) | 0px | Galaxy Fold 접힌 (344px) |
|
||||
| `xs` | 375px | iPhone SE, 소형 모바일 |
|
||||
| `sm` | 640px | 대형 모바일, 소형 태블릿 |
|
||||
| `md` | 768px | 태블릿 |
|
||||
| `lg` | 1024px | 소형 데스크탑 |
|
||||
| `xl` | 1280px | 데스크탑 |
|
||||
| `2xl` | 1536px | 대형 데스크탑 |
|
||||
|
||||
### 1.2 커스텀 브레이크포인트 (tailwind.config.js)
|
||||
|
||||
```javascript
|
||||
// tailwind.config.js
|
||||
module.exports = {
|
||||
theme: {
|
||||
screens: {
|
||||
'xs': '375px', // iPhone SE
|
||||
'sm': '640px',
|
||||
'md': '768px',
|
||||
'lg': '1024px',
|
||||
'xl': '1280px',
|
||||
'2xl': '1536px',
|
||||
// Galaxy Fold 전용 (선택적)
|
||||
'fold': '344px',
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 1.3 주요 테스트 뷰포트
|
||||
|
||||
| 기기 | 너비 | 높이 | 우선순위 |
|
||||
|------|------|------|----------|
|
||||
| Galaxy Z Fold 5 (접힌) | **344px** | 882px | 🔴 필수 |
|
||||
| iPhone SE | 375px | 667px | 🔴 필수 |
|
||||
| iPhone 14 Pro | 393px | 852px | 🟡 권장 |
|
||||
| iPad Mini | 768px | 1024px | 🟡 권장 |
|
||||
| Desktop | 1280px+ | - | 🟢 기본 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 공통 패턴별 해결책
|
||||
|
||||
### 2.1 그리드 레이아웃
|
||||
|
||||
#### 문제
|
||||
344px에서 `grid-cols-2`는 각 항목이 ~160px로 좁아져 텍스트 오버플로우 발생
|
||||
|
||||
#### 해결 패턴
|
||||
|
||||
**패턴 A: 1열 → 2열 → 4열 (권장)**
|
||||
```tsx
|
||||
// Before
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
|
||||
// After - 344px에서 1열
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-4">
|
||||
```
|
||||
|
||||
**패턴 B: 최소 너비 보장**
|
||||
```tsx
|
||||
// 카드 최소 너비 보장 + 자동 열 조정
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
||||
```
|
||||
|
||||
**패턴 C: Flex Wrap (항목 수 가변적일 때)**
|
||||
```tsx
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="w-full xs:w-[calc(50%-0.5rem)] md:w-[calc(25%-0.75rem)]">
|
||||
{/* 카드 내용 */}
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### 적용 기준
|
||||
| 카드 개수 | 권장 패턴 |
|
||||
|-----------|----------|
|
||||
| 1-2개 | `grid-cols-1 xs:grid-cols-2` |
|
||||
| 3-4개 | `grid-cols-1 xs:grid-cols-2 md:grid-cols-4` |
|
||||
| 5개+ | `grid-cols-1 xs:grid-cols-2 md:grid-cols-3 lg:grid-cols-4` |
|
||||
|
||||
---
|
||||
|
||||
### 2.2 테이블 반응형
|
||||
|
||||
#### 문제
|
||||
테이블이 344px 화면에서 가로 스크롤 없이 표시 불가
|
||||
|
||||
#### 해결 패턴
|
||||
|
||||
**패턴 A: 가로 스크롤 (기본)**
|
||||
```tsx
|
||||
<div className="overflow-x-auto -mx-4 px-4 md:mx-0 md:px-0">
|
||||
<table className="min-w-[600px] w-full">
|
||||
{/* 테이블 내용 */}
|
||||
</table>
|
||||
</div>
|
||||
```
|
||||
|
||||
**패턴 B: 카드형 변환 (복잡한 데이터)**
|
||||
```tsx
|
||||
{/* 데스크탑: 테이블 */}
|
||||
<table className="hidden md:table">
|
||||
{/* 테이블 내용 */}
|
||||
</table>
|
||||
|
||||
{/* 모바일: 카드 리스트 */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{data.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-muted-foreground">거래처</span>
|
||||
<span className="font-medium">{item.vendor}</span>
|
||||
</div>
|
||||
{/* 추가 필드 */}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
```
|
||||
|
||||
**패턴 C: 컬럼 숨김 (우선순위 기반)**
|
||||
```tsx
|
||||
<th className="hidden sm:table-cell">등록일</th>
|
||||
<th className="hidden md:table-cell">수정일</th>
|
||||
<th>필수 컬럼</th>
|
||||
|
||||
<td className="hidden sm:table-cell">{item.createdAt}</td>
|
||||
<td className="hidden md:table-cell">{item.updatedAt}</td>
|
||||
<td>{item.essential}</td>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.3 카드 컴포넌트
|
||||
|
||||
#### 문제
|
||||
카드 내 금액, 라벨이 좁은 화면에서 잘림
|
||||
|
||||
#### 해결 패턴
|
||||
|
||||
**패턴 A: 텍스트 크기 반응형**
|
||||
```tsx
|
||||
// Before
|
||||
<p className="text-3xl font-bold">30,500,000,000원</p>
|
||||
|
||||
// After
|
||||
<p className="text-xl xs:text-2xl md:text-3xl font-bold">30.5억원</p>
|
||||
```
|
||||
|
||||
**패턴 B: 금액 포맷 함수 개선**
|
||||
```typescript
|
||||
// utils/format.ts
|
||||
export const formatAmountResponsive = (amount: number, compact = false): string => {
|
||||
if (compact || amount >= 100000000) {
|
||||
// 억 단위
|
||||
const billion = amount / 100000000;
|
||||
return billion >= 1 ? `${billion.toFixed(1)}억원` : formatAmount(amount);
|
||||
}
|
||||
if (amount >= 10000) {
|
||||
// 만 단위
|
||||
const man = amount / 10000;
|
||||
return `${man.toFixed(0)}만원`;
|
||||
}
|
||||
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
|
||||
};
|
||||
```
|
||||
|
||||
**패턴 C: 라벨 줄바꿈 허용**
|
||||
```tsx
|
||||
// Before
|
||||
<p className="text-sm whitespace-nowrap">현금성 자산 합계</p>
|
||||
|
||||
// After
|
||||
<p className="text-sm break-keep">현금성 자산 합계</p>
|
||||
```
|
||||
|
||||
**패턴 D: Truncate + Tooltip**
|
||||
```tsx
|
||||
<p className="text-sm truncate max-w-full" title={longLabel}>
|
||||
{longLabel}
|
||||
</p>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.4 모달/다이얼로그
|
||||
|
||||
#### 문제
|
||||
모달이 344px 화면에서 좌우 여백 없이 꽉 차거나 넘침
|
||||
|
||||
#### 해결 패턴
|
||||
|
||||
**패턴 A: 최대 너비 반응형**
|
||||
```tsx
|
||||
// Before
|
||||
<DialogContent className="max-w-2xl">
|
||||
|
||||
// After
|
||||
<DialogContent className="max-w-[calc(100vw-2rem)] sm:max-w-lg md:max-w-2xl">
|
||||
```
|
||||
|
||||
**패턴 B: 전체 화면 모달 (복잡한 내용)**
|
||||
```tsx
|
||||
<DialogContent className="w-full h-full max-w-none sm:max-w-2xl sm:h-auto sm:max-h-[90vh]">
|
||||
```
|
||||
|
||||
**패턴 C: 모달 내부 스크롤**
|
||||
```tsx
|
||||
<DialogContent className="max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
{/* 헤더 */}
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 스크롤 가능한 내용 */}
|
||||
</div>
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
{/* 푸터 */}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.5 버튼 그룹
|
||||
|
||||
#### 문제
|
||||
여러 버튼이 가로로 나열될 때 344px에서 넘침
|
||||
|
||||
#### 해결 패턴
|
||||
|
||||
**패턴 A: Flex Wrap**
|
||||
```tsx
|
||||
// Before
|
||||
<div className="flex gap-2">
|
||||
<Button>저장</Button>
|
||||
<Button>취소</Button>
|
||||
<Button>삭제</Button>
|
||||
</div>
|
||||
|
||||
// After
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button className="flex-1 min-w-[80px]">저장</Button>
|
||||
<Button className="flex-1 min-w-[80px]">취소</Button>
|
||||
<Button className="flex-1 min-w-[80px]">삭제</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**패턴 B: 세로 배치 (모바일)**
|
||||
```tsx
|
||||
<div className="flex flex-col xs:flex-row gap-2">
|
||||
<Button className="w-full xs:w-auto">저장</Button>
|
||||
<Button className="w-full xs:w-auto">취소</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
**패턴 C: 아이콘 전용 (극소 화면)**
|
||||
```tsx
|
||||
<Button className="gap-2">
|
||||
<SaveIcon className="h-4 w-4" />
|
||||
<span className="hidden xs:inline">저장</span>
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.6 긴 텍스트 처리
|
||||
|
||||
#### 문제
|
||||
긴 제목, 설명, 메시지가 좁은 화면에서 레이아웃 깨짐
|
||||
|
||||
#### 해결 패턴
|
||||
|
||||
**패턴 A: Truncate (한 줄)**
|
||||
```tsx
|
||||
<h3 className="truncate max-w-full" title={title}>
|
||||
{title}
|
||||
</h3>
|
||||
```
|
||||
|
||||
**패턴 B: Line Clamp (여러 줄)**
|
||||
```tsx
|
||||
<p className="line-clamp-2 text-sm text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
```
|
||||
|
||||
**패턴 C: Break Keep (한글 단어 단위)**
|
||||
```tsx
|
||||
<p className="break-keep">
|
||||
가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의
|
||||
</p>
|
||||
```
|
||||
|
||||
**패턴 D: 반응형 텍스트 크기**
|
||||
```tsx
|
||||
<h1 className="text-lg xs:text-xl md:text-2xl font-bold break-keep">
|
||||
{title}
|
||||
</h1>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.7 헤더/네비게이션
|
||||
|
||||
#### 문제
|
||||
페이지 헤더의 타이틀과 액션 버튼이 충돌
|
||||
|
||||
#### 해결 패턴
|
||||
|
||||
**패턴 A: 세로 배치 (모바일)**
|
||||
```tsx
|
||||
<div className="flex flex-col xs:flex-row xs:items-center xs:justify-between gap-2">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm">액션</Button>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**패턴 B: 아이콘 버튼 (극소 화면)**
|
||||
```tsx
|
||||
<Button size="sm" className="gap-1.5">
|
||||
<SettingsIcon className="h-4 w-4" />
|
||||
<span className="hidden xs:inline">항목 설정</span>
|
||||
</Button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2.8 패딩/마진 반응형
|
||||
|
||||
#### 문제
|
||||
데스크탑용 패딩이 모바일에서 공간 낭비
|
||||
|
||||
#### 해결 패턴
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<div className="p-6">
|
||||
|
||||
// After
|
||||
<div className="p-3 xs:p-4 md:p-6">
|
||||
|
||||
// 카드 내부
|
||||
<CardContent className="p-3 xs:p-4 md:p-6">
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Tailwind 유틸리티 클래스 모음
|
||||
|
||||
### 3.1 자주 사용하는 반응형 패턴
|
||||
|
||||
```css
|
||||
/* 그리드 */
|
||||
.grid-responsive-1-2-4: grid-cols-1 xs:grid-cols-2 md:grid-cols-4
|
||||
.grid-responsive-1-2-3: grid-cols-1 xs:grid-cols-2 md:grid-cols-3
|
||||
.grid-responsive-1-3: grid-cols-1 md:grid-cols-3
|
||||
|
||||
/* 텍스트 */
|
||||
.text-responsive-sm: text-xs xs:text-sm
|
||||
.text-responsive-base: text-sm xs:text-base
|
||||
.text-responsive-lg: text-base xs:text-lg md:text-xl
|
||||
.text-responsive-xl: text-lg xs:text-xl md:text-2xl
|
||||
.text-responsive-2xl: text-xl xs:text-2xl md:text-3xl
|
||||
|
||||
/* 패딩 */
|
||||
.p-responsive: p-3 xs:p-4 md:p-6
|
||||
.px-responsive: px-3 xs:px-4 md:px-6
|
||||
.py-responsive: py-3 xs:py-4 md:py-6
|
||||
|
||||
/* 갭 */
|
||||
.gap-responsive: gap-2 xs:gap-3 md:gap-4
|
||||
|
||||
/* Flex 방향 */
|
||||
.flex-col-to-row: flex-col xs:flex-row
|
||||
```
|
||||
|
||||
### 3.2 커스텀 유틸리티 (globals.css)
|
||||
|
||||
```css
|
||||
/* globals.css */
|
||||
@layer utilities {
|
||||
.grid-responsive-cards {
|
||||
@apply grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4;
|
||||
}
|
||||
|
||||
.text-amount {
|
||||
@apply text-xl xs:text-2xl md:text-3xl font-bold;
|
||||
}
|
||||
|
||||
.card-padding {
|
||||
@apply p-3 xs:p-4 md:p-6;
|
||||
}
|
||||
|
||||
.section-padding {
|
||||
@apply p-4 xs:p-5 md:p-6;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 적용 체크리스트
|
||||
|
||||
### 4.1 페이지 단위 체크리스트
|
||||
|
||||
```markdown
|
||||
## 페이지: [페이지명]
|
||||
테스트 뷰포트: 344px (Galaxy Fold)
|
||||
|
||||
### 레이아웃
|
||||
- [ ] 헤더 타이틀/액션 버튼 충돌 없음
|
||||
- [ ] 그리드 카드 오버플로우 없음
|
||||
- [ ] 사이드바 접힘 상태 정상
|
||||
|
||||
### 텍스트
|
||||
- [ ] 제목 텍스트 잘림/줄바꿈 정상
|
||||
- [ ] 금액 표시 가독성 확보
|
||||
- [ ] 라벨 텍스트 truncate 또는 줄바꿈
|
||||
|
||||
### 테이블
|
||||
- [ ] 가로 스크롤 정상 동작
|
||||
- [ ] 필수 컬럼 표시 확인
|
||||
- [ ] 체크박스/액션 버튼 접근 가능
|
||||
|
||||
### 카드
|
||||
- [ ] 카드 내용 오버플로우 없음
|
||||
- [ ] 터치 영역 충분 (최소 44px)
|
||||
- [ ] 카드 간 간격 적절
|
||||
|
||||
### 모달
|
||||
- [ ] 화면 내 완전히 표시
|
||||
- [ ] 닫기 버튼 접근 가능
|
||||
- [ ] 내부 스크롤 정상
|
||||
|
||||
### 버튼
|
||||
- [ ] 버튼 그룹 wrap 정상
|
||||
- [ ] 터치 영역 충분
|
||||
- [ ] 아이콘/텍스트 가독성
|
||||
```
|
||||
|
||||
### 4.2 컴포넌트 단위 체크리스트
|
||||
|
||||
```markdown
|
||||
## 컴포넌트: [컴포넌트명]
|
||||
|
||||
### 필수 확인
|
||||
- [ ] min-width 고정값 없음 또는 반응형 처리
|
||||
- [ ] whitespace-nowrap 사용 시 truncate 동반
|
||||
- [ ] grid-cols-N 사용 시 모바일 breakpoint 추가
|
||||
- [ ] 패딩/마진 반응형 적용
|
||||
|
||||
### 권장 확인
|
||||
- [ ] 텍스트 크기 반응형
|
||||
- [ ] 버튼 크기 반응형
|
||||
- [ ] 아이콘 크기 반응형
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 적용 사례
|
||||
|
||||
### 5.1 CEO 대시보드 적용 예정
|
||||
|
||||
**현재 문제점**:
|
||||
- `grid-cols-2 md:grid-cols-4` → 344px에서 카드당 ~160px
|
||||
- 금액 "3,050,000,000원" 표시 → 잘림
|
||||
- "현금성 자산 합계" 라벨 → 잘림
|
||||
|
||||
**적용 계획**:
|
||||
1. 그리드: `grid-cols-1 xs:grid-cols-2 md:grid-cols-4`
|
||||
2. 금액: `formatAmountResponsive()` 함수 사용 (억 단위)
|
||||
3. 라벨: `break-keep` 또는 `truncate`
|
||||
4. 카드 패딩: `p-3 xs:p-4 md:p-6`
|
||||
5. 헤더 버튼: 아이콘 전용 옵션
|
||||
|
||||
**상세 계획**: `[PLAN] ceo-dashboard-refactoring.md` 참조
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 방법
|
||||
|
||||
### 6.1 Chrome DevTools 설정
|
||||
|
||||
1. DevTools 열기 (F12)
|
||||
2. Device Toolbar (Ctrl+Shift+M)
|
||||
3. Edit → Add custom device:
|
||||
- Name: `Galaxy Z Fold 5 (Folded)`
|
||||
- Width: `344`
|
||||
- Height: `882`
|
||||
- Device pixel ratio: `3`
|
||||
- User agent: Mobile
|
||||
|
||||
### 6.2 권장 테스트 순서
|
||||
|
||||
1. **344px**: 최소 지원 너비 (Galaxy Fold)
|
||||
2. **375px**: iPhone SE
|
||||
3. **768px**: 태블릿
|
||||
4. **1280px**: 데스크탑
|
||||
|
||||
### 6.3 자동화 테스트 (Playwright)
|
||||
|
||||
```typescript
|
||||
// playwright.config.ts
|
||||
const devices = [
|
||||
{ name: 'Galaxy Fold', viewport: { width: 344, height: 882 } },
|
||||
{ name: 'iPhone SE', viewport: { width: 375, height: 667 } },
|
||||
{ name: 'iPad', viewport: { width: 768, height: 1024 } },
|
||||
{ name: 'Desktop', viewport: { width: 1280, height: 800 } },
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 버전 | 변경 내용 |
|
||||
|------|------|----------|
|
||||
| 2026-01-10 | 1.0 | 초기 작성 |
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { BoardDetail } from '@/components/board/BoardDetail';
|
||||
import { getPost } from '@/components/board/actions';
|
||||
import type { Post, Comment } from '@/components/board/types';
|
||||
@@ -60,11 +60,7 @@ export default function BoardDetailPage() {
|
||||
}, [boardCode, postId, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="게시글을 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!post) {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { BoardForm } from '@/components/board/BoardManagement/BoardForm';
|
||||
import { getBoardById, updateBoard } from '@/components/board/BoardManagement/actions';
|
||||
import { forceRefreshMenus } from '@/lib/utils/menuRefresh';
|
||||
@@ -64,11 +65,7 @@ export default function BoardEditPage() {
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { BoardDetail } from '@/components/board/BoardManagement/BoardDetail';
|
||||
import { getBoardById, deleteBoard } from '@/components/board/BoardManagement/actions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -74,11 +75,7 @@ export default function BoardDetailPage() {
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="게시판 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { ArrowLeft, Save, MessageSquare, Loader2 } from 'lucide-react';
|
||||
import { ArrowLeft, Save, MessageSquare } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -146,9 +147,7 @@ export default function DynamicBoardEditPage() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
<ContentLoadingSpinner text="게시글을 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import DynamicItemForm from '@/components/items/DynamicItemForm';
|
||||
import type { DynamicFormData, BOMLine } from '@/components/items/DynamicItemForm/types';
|
||||
import type { ItemType } from '@/types/item';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import {
|
||||
isMaterialType,
|
||||
transformMaterialDataForSave,
|
||||
@@ -391,12 +391,7 @@ export default function EditItemPage() {
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">품목 정보 로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="품목 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { notFound } from 'next/navigation';
|
||||
import ItemDetailClient from '@/components/items/ItemDetailClient';
|
||||
import type { ItemMaster, ItemType, ProductCategory, PartType, PartUsage } from '@/types/item';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
|
||||
// Materials 타입 (SM, RM, CS는 Material 테이블 사용)
|
||||
const MATERIAL_TYPES = ['SM', 'RM', 'CS'];
|
||||
@@ -255,12 +255,7 @@ export default function ItemDetailPage() {
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 gap-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">품목 정보 로딩 중...</p>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="품목 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import ProductionDashboard from '@/components/production/ProductionDashboard';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
|
||||
export default function ProductionDashboardPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<Suspense fallback={<ContentLoadingSpinner text="생산 현황을 불러오는 중..." />}>
|
||||
<ProductionDashboard />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import ItemForm from '@/components/items/ItemForm';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
@@ -189,11 +190,7 @@ export default function EditItemPage() {
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="품목 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
import { use, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import ItemDetailClient from '@/components/items/ItemDetailClient';
|
||||
import type { ItemMaster } from '@/types/item';
|
||||
|
||||
@@ -159,11 +160,7 @@ export default function ItemDetailPage({
|
||||
}, [id]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="품목 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
|
||||
import { Suspense } from 'react';
|
||||
import WorkerScreen from '@/components/production/WorkerScreen';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
|
||||
export default function WorkerScreenPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="text-center py-8">로딩 중...</div>}>
|
||||
<Suspense fallback={<ContentLoadingSpinner text="작업자 화면을 불러오는 중..." />}>
|
||||
<WorkerScreen />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
@@ -97,17 +97,17 @@ export function Day1ChecklistPanel({
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 + 검색 */}
|
||||
<div className="bg-gray-100 px-4 py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900 mb-2">점검표 항목</h3>
|
||||
<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 mb-2">점검표 항목</h3>
|
||||
{/* 검색 입력 */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Search className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="항목 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-9 pr-8 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
className="w-full pl-8 sm:pl-9 pr-8 py-1.5 sm:py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
@@ -121,7 +121,7 @@ export function Day1ChecklistPanel({
|
||||
</div>
|
||||
{/* 검색 결과 카운트 */}
|
||||
{searchTerm && (
|
||||
<div className="mt-2 text-xs text-gray-500">
|
||||
<div className="mt-1.5 sm:mt-2 text-xs text-gray-500">
|
||||
{filteredCategories.length > 0
|
||||
? `${filteredCategories.reduce((sum, cat) => sum + cat.subItems.length, 0)}개 항목 검색됨`
|
||||
: '검색 결과가 없습니다'
|
||||
@@ -151,7 +151,7 @@ export function Day1ChecklistPanel({
|
||||
type="button"
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between px-4 py-3 text-left transition-colors',
|
||||
'w-full flex items-center justify-between px-2 sm:px-4 py-2 sm:py-3 text-left transition-colors',
|
||||
'hover:bg-gray-50',
|
||||
allCompleted && 'bg-green-50'
|
||||
)}
|
||||
@@ -231,7 +231,7 @@ function SubItemRow({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-2.5 cursor-pointer transition-colors',
|
||||
'flex items-center gap-2 sm:gap-3 px-2 sm:px-4 py-2 sm:py-2.5 cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'bg-blue-100 border-l-4 border-blue-500'
|
||||
: 'hover:bg-gray-100 border-l-4 border-transparent',
|
||||
|
||||
@@ -35,22 +35,22 @@ export function Day1DocumentSection({
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-4 py-3 border-b border-gray-200">
|
||||
<h3 className="font-semibold text-gray-900">기준 문서화</h3>
|
||||
<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>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div className="flex-1 overflow-y-auto p-3 sm:p-4 space-y-3 sm:space-y-4">
|
||||
{/* 항목 정보 */}
|
||||
<div className="bg-blue-50 rounded-lg p-4">
|
||||
<h4 className="font-medium text-blue-900 mb-2">{checkItem.title}</h4>
|
||||
<p className="text-sm text-blue-700">{checkItem.description}</p>
|
||||
<div className="bg-blue-50 rounded-lg p-3 sm:p-4">
|
||||
<h4 className="font-medium text-blue-900 mb-1 sm:mb-2 text-sm sm:text-base">{checkItem.title}</h4>
|
||||
<p className="text-xs sm:text-sm text-blue-700">{checkItem.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 기준 문서 목록 */}
|
||||
<div>
|
||||
<h5 className="text-sm font-medium text-gray-700 mb-2">관련 기준 문서</h5>
|
||||
<div className="space-y-2">
|
||||
<h5 className="text-xs sm:text-sm font-medium text-gray-700 mb-1.5 sm:mb-2">관련 기준 문서</h5>
|
||||
<div className="space-y-1.5 sm:space-y-2">
|
||||
{checkItem.standardDocuments.map((doc) => (
|
||||
<DocumentRow
|
||||
key={doc.id}
|
||||
@@ -63,7 +63,7 @@ export function Day1DocumentSection({
|
||||
</div>
|
||||
|
||||
{/* 확인 버튼 */}
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<div className="pt-3 sm:pt-4 border-t border-gray-200">
|
||||
<Button
|
||||
onClick={onConfirmComplete}
|
||||
disabled={isCompleted}
|
||||
@@ -101,7 +101,7 @@ function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3 rounded-lg border cursor-pointer transition-colors',
|
||||
'flex items-center gap-2 sm:gap-3 p-2 sm:p-3 rounded-lg border cursor-pointer transition-colors',
|
||||
isSelected
|
||||
? 'bg-blue-50 border-blue-300'
|
||||
: 'bg-gray-50 border-gray-200 hover:bg-gray-100'
|
||||
@@ -110,12 +110,12 @@ function DocumentRow({ document, isSelected, onSelect }: DocumentRowProps) {
|
||||
>
|
||||
{/* 아이콘 */}
|
||||
<div className={cn(
|
||||
'flex-shrink-0 w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
'flex-shrink-0 w-8 h-8 sm:w-10 sm:h-10 rounded-lg flex items-center justify-center',
|
||||
document.fileName?.endsWith('.pdf')
|
||||
? 'bg-red-100 text-red-600'
|
||||
: 'bg-green-100 text-green-600'
|
||||
)}>
|
||||
<FileText className="h-5 w-5" />
|
||||
<FileText className="h-4 w-4 sm:h-5 sm:w-5" />
|
||||
</div>
|
||||
|
||||
{/* 문서 정보 */}
|
||||
|
||||
@@ -27,19 +27,19 @@ export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden h-full flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-100 px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
||||
<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={cn(
|
||||
'w-8 h-8 rounded flex items-center justify-center',
|
||||
'w-6 h-6 sm:w-8 sm:h-8 rounded flex items-center justify-center',
|
||||
isPdf ? 'bg-red-100 text-red-600' :
|
||||
isExcel ? 'bg-green-100 text-green-600' :
|
||||
'bg-gray-200 text-gray-600'
|
||||
)}>
|
||||
<FileText className="h-4 w-4" />
|
||||
<FileText className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900 text-sm">{document.title}</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
<h3 className="font-medium text-gray-900 text-xs sm:text-sm">{document.title}</h3>
|
||||
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||
{document.version !== '-' && <span className="mr-2">{document.version}</span>}
|
||||
{document.date}
|
||||
</p>
|
||||
@@ -47,60 +47,60 @@ export function Day1DocumentViewer({ document }: Day1DocumentViewerProps) {
|
||||
</div>
|
||||
|
||||
{/* 툴바 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-0.5 sm:gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="축소"
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
<ZoomOut className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
className="p-1.5 sm:p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="확대"
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
<ZoomIn className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
className="hidden sm:block p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="전체화면"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="w-px h-6 bg-gray-300 mx-1" />
|
||||
<div className="hidden sm:block w-px h-6 bg-gray-300 mx-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
className="hidden sm:block p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
title="인쇄"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="p-2 rounded hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
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-4 w-4" />
|
||||
<Download className="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 미리보기 영역 */}
|
||||
<div className="flex-1 bg-gray-200 p-4 overflow-auto">
|
||||
<div className="bg-white rounded shadow-lg max-w-3xl mx-auto min-h-[600px]">
|
||||
<div className="flex-1 bg-gray-200 p-2 sm:p-4 overflow-auto">
|
||||
<div className="bg-white rounded shadow-lg max-w-3xl mx-auto min-h-[400px] sm:min-h-[600px]">
|
||||
{/* Mock 문서 내용 */}
|
||||
<DocumentPreviewContent document={document} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
<div className="bg-gray-100 px-4 py-2 border-t border-gray-200 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
<div className="bg-gray-100 px-2 sm:px-4 py-1.5 sm:py-2 border-t border-gray-200 flex items-center justify-between">
|
||||
<span className="text-[10px] sm:text-xs text-gray-500 truncate max-w-[60%]">
|
||||
파일명: {document.fileName || '-'}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
<span className="text-[10px] sm:text-xs text-gray-500">
|
||||
1 / 1 페이지
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -25,24 +25,27 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="mb-4 space-y-3">
|
||||
<div className="mb-3 md:mb-4 space-y-2 md:space-y-3">
|
||||
{/* 탭 버튼 */}
|
||||
<div className="flex gap-3">
|
||||
<div className="flex gap-2 md:gap-3">
|
||||
{/* 1일차 탭 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDayChange(1)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg border-2 transition-all',
|
||||
'flex-1 flex items-center justify-center gap-1 sm:gap-2 py-2 sm:py-3 px-2 sm:px-4 rounded-lg border-2 transition-all',
|
||||
activeDay === 1
|
||||
? 'bg-blue-600 border-blue-600 text-white shadow-lg'
|
||||
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300 hover:bg-blue-50'
|
||||
)}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="font-medium">1일차: 기준/매뉴얼 심사</span>
|
||||
<Calendar className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium text-xs sm:text-sm">
|
||||
<span className="hidden sm:inline">1일차: 기준/매뉴얼</span>
|
||||
<span className="sm:hidden">1일차</span>
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full ml-2',
|
||||
'text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full ml-1 sm:ml-2 shrink-0',
|
||||
activeDay === 1 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600'
|
||||
)}>
|
||||
{day1Progress.completed}/{day1Progress.total}
|
||||
@@ -54,16 +57,19 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
||||
type="button"
|
||||
onClick={() => onDayChange(2)}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg border-2 transition-all',
|
||||
'flex-1 flex items-center justify-center gap-1 sm:gap-2 py-2 sm:py-3 px-2 sm:px-4 rounded-lg border-2 transition-all',
|
||||
activeDay === 2
|
||||
? 'bg-blue-600 border-blue-600 text-white shadow-lg'
|
||||
: 'bg-white border-gray-200 text-gray-700 hover:border-blue-300 hover:bg-blue-50'
|
||||
)}
|
||||
>
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span className="font-medium">2일차: 로트추적 심사</span>
|
||||
<Calendar className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium text-xs sm:text-sm">
|
||||
<span className="hidden sm:inline">2일차: 로트추적</span>
|
||||
<span className="sm:hidden">2일차</span>
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full ml-2',
|
||||
'text-[10px] sm:text-xs px-1.5 sm:px-2 py-0.5 rounded-full ml-1 sm:ml-2 shrink-0',
|
||||
activeDay === 2 ? 'bg-blue-500 text-white' : 'bg-gray-100 text-gray-600'
|
||||
)}>
|
||||
{day2Progress.completed}/{day2Progress.total}
|
||||
@@ -72,11 +78,14 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
||||
</div>
|
||||
|
||||
{/* 진행률 - 3줄 표시 */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 px-4 py-3 space-y-2">
|
||||
<div className="bg-white rounded-lg border border-gray-200 px-2 sm:px-4 py-2 sm:py-3 space-y-1.5 sm:space-y-2">
|
||||
{/* 전체 심사 진행률 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-gray-700 w-28 shrink-0">전체 심사</span>
|
||||
<div className="flex-1 h-2.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm font-medium text-gray-700 w-14 sm:w-28 shrink-0">
|
||||
<span className="hidden sm:inline">전체 심사</span>
|
||||
<span className="sm:hidden">전체</span>
|
||||
</span>
|
||||
<div className="flex-1 h-2 sm:h-2.5 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
@@ -86,7 +95,7 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
||||
/>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-sm font-bold w-16 text-right',
|
||||
'text-xs sm:text-sm font-bold w-12 sm:w-16 text-right shrink-0',
|
||||
overallPercentage === 100 ? 'text-green-600' : 'text-blue-600'
|
||||
)}>
|
||||
{totalCompleted}/{totalItems}
|
||||
@@ -94,9 +103,12 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
||||
</div>
|
||||
|
||||
{/* 1일차 진행률 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 w-28 shrink-0">1일차: 기준/매뉴얼</span>
|
||||
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm text-gray-600 w-14 sm:w-28 shrink-0">
|
||||
<span className="hidden sm:inline">1일차: 기준/매뉴얼</span>
|
||||
<span className="sm:hidden">1일차</span>
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 sm:h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
@@ -106,7 +118,7 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
||||
/>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-sm font-medium w-16 text-right',
|
||||
'text-xs sm:text-sm font-medium w-12 sm:w-16 text-right shrink-0',
|
||||
day1Percentage === 100 ? 'text-green-600' : 'text-gray-600'
|
||||
)}>
|
||||
{day1Progress.completed}/{day1Progress.total}
|
||||
@@ -114,9 +126,12 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
||||
</div>
|
||||
|
||||
{/* 2일차 진행률 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-gray-600 w-28 shrink-0">2일차: 로트추적</span>
|
||||
<div className="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<span className="text-xs sm:text-sm text-gray-600 w-14 sm:w-28 shrink-0">
|
||||
<span className="hidden sm:inline">2일차: 로트추적</span>
|
||||
<span className="sm:hidden">2일차</span>
|
||||
</span>
|
||||
<div className="flex-1 h-1.5 sm:h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-500',
|
||||
@@ -126,7 +141,7 @@ export function DayTabs({ activeDay, onDayChange, day1Progress, day2Progress }:
|
||||
/>
|
||||
</div>
|
||||
<span className={cn(
|
||||
'text-sm font-medium w-16 text-right',
|
||||
'text-xs sm:text-sm font-medium w-12 sm:w-16 text-right shrink-0',
|
||||
day2Percentage === 100 ? 'text-green-600' : 'text-gray-600'
|
||||
)}>
|
||||
{day2Progress.completed}/{day2Progress.total}
|
||||
|
||||
@@ -51,15 +51,15 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||
<h2 className="font-bold text-gray-800 text-sm mb-4">
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
|
||||
관련 서류{' '}
|
||||
{routeCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({routeCode})</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-3 overflow-y-auto flex-1">
|
||||
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
||||
{!routeCode ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||
수주루트를 선택해주세요.
|
||||
@@ -74,7 +74,7 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
|
||||
<div key={doc.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div
|
||||
onClick={() => handleDocClick(doc)}
|
||||
className={`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'
|
||||
} ${isExpanded ? 'bg-green-50' : 'bg-white'}`}
|
||||
>
|
||||
@@ -99,13 +99,13 @@ export const DocumentList = ({ documents, routeCode, onViewDocument }: DocumentL
|
||||
</div>
|
||||
|
||||
{isExpanded && hasMultipleItems && (
|
||||
<div className="bg-white px-4 pb-4 space-y-2">
|
||||
<div className="h-px bg-gray-100 w-full mb-3" />
|
||||
<div className="bg-white px-3 sm:px-4 pb-3 sm:pb-4 space-y-1.5 sm:space-y-2">
|
||||
<div className="h-px bg-gray-100 w-full mb-2 sm:mb-3" />
|
||||
{doc.items!.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(doc, item)}
|
||||
className="flex items-center justify-between border border-gray-100 p-3 rounded cursor-pointer hover:bg-green-50 hover:border-green-200 transition-colors group"
|
||||
className="flex items-center justify-between border border-gray-100 p-2 sm:p-3 rounded cursor-pointer hover:bg-green-50 hover:border-green-200 transition-colors group"
|
||||
>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-gray-700">{item.title}</div>
|
||||
|
||||
@@ -24,13 +24,13 @@ export const Filters = ({
|
||||
const years = [2025, 2024, 2023, 2022, 2021];
|
||||
|
||||
return (
|
||||
<div className="w-full bg-white p-4 rounded-lg mb-4 shadow-sm">
|
||||
<div className="w-full bg-white p-3 sm:p-4 rounded-lg mb-3 sm:mb-4 shadow-sm">
|
||||
{/* 상단: 년도/분기 선택 */}
|
||||
<div className="flex flex-wrap items-end gap-4 mb-4">
|
||||
<div className="flex flex-wrap items-end gap-3 sm:gap-4 mb-3 sm:mb-4">
|
||||
{/* Year Selection */}
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-xs font-semibold text-gray-500">년도</span>
|
||||
<div className="w-32">
|
||||
<div className="w-28 sm:w-32">
|
||||
<select
|
||||
value={selectedYear}
|
||||
onChange={(e) => onYearChange(parseInt(e.target.value))}
|
||||
@@ -78,7 +78,7 @@ export const Filters = ({
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-md text-sm focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button className="bg-[#1e3a8a] text-white px-6 py-2 rounded-md text-sm hover:bg-blue-800 transition-colors whitespace-nowrap">
|
||||
<button className="bg-[#1e3a8a] text-white px-4 sm:px-6 py-2 rounded-md text-sm hover:bg-blue-800 transition-colors whitespace-nowrap">
|
||||
조회
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -6,10 +6,10 @@ interface HeaderProps {
|
||||
|
||||
export const Header = ({ rightContent }: HeaderProps) => {
|
||||
return (
|
||||
<div className="w-full bg-[#1e3a8a] text-white p-6 rounded-lg mb-4 shadow-md flex items-center justify-between h-24">
|
||||
<div className="w-full bg-[#1e3a8a] text-white p-3 sm:p-6 rounded-lg mb-3 sm:mb-4 shadow-md flex items-center justify-between h-16 sm:h-24">
|
||||
<div className="flex flex-col justify-center">
|
||||
<h1 className="text-2xl font-bold mb-1">품질인정심사 시스템</h1>
|
||||
<p className="text-sm opacity-80 text-blue-100">SAM - Smart Automation Management</p>
|
||||
<h1 className="text-lg sm:text-2xl font-bold mb-0.5 sm:mb-1">품질인정심사 시스템</h1>
|
||||
<p className="text-xs sm:text-sm opacity-80 text-blue-100">SAM - Smart Automation Management</p>
|
||||
</div>
|
||||
{rightContent && (
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -12,15 +12,15 @@ interface ReportListProps {
|
||||
|
||||
export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) => {
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-bold text-lg text-gray-800">품질관리서 목록</h2>
|
||||
<span className="bg-blue-100 text-blue-800 text-xs font-bold px-2 py-1 rounded-full">
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3 sm:mb-4">
|
||||
<h2 className="font-bold text-sm sm:text-lg text-gray-800">품질관리서 목록</h2>
|
||||
<span className="bg-blue-100 text-blue-800 text-[10px] sm:text-xs font-bold px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full">
|
||||
{reports.length}건
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 overflow-y-auto flex-1">
|
||||
<div className="space-y-2 sm:space-y-3 overflow-y-auto flex-1">
|
||||
{reports.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||
해당 조건의 품질관리서가 없습니다.
|
||||
@@ -32,23 +32,23 @@ export const ReportList = ({ reports, selectedId, onSelect }: ReportListProps) =
|
||||
<div
|
||||
key={report.id}
|
||||
onClick={() => onSelect(report)}
|
||||
className={`rounded-lg p-4 cursor-pointer relative hover:shadow-md transition-all ${
|
||||
className={`rounded-lg p-3 sm:p-4 cursor-pointer relative hover:shadow-md transition-all ${
|
||||
isSelected
|
||||
? 'border-2 border-blue-500 bg-blue-50'
|
||||
: 'border border-gray-200 bg-white hover:border-blue-300'
|
||||
}`}
|
||||
>
|
||||
<div className="absolute top-4 right-4 text-xs text-gray-400 bg-gray-100 px-2 py-1 rounded">
|
||||
<div className="absolute top-3 sm:top-4 right-3 sm:right-4 text-[10px] sm:text-xs text-gray-400 bg-gray-100 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded">
|
||||
{report.quarter}
|
||||
</div>
|
||||
|
||||
<h3 className={`font-bold text-lg mb-1 ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
|
||||
<h3 className={`font-bold text-sm sm:text-lg mb-1 ${isSelected ? 'text-blue-900' : 'text-gray-800'}`}>
|
||||
{report.code}
|
||||
</h3>
|
||||
<p className="text-gray-700 font-medium mb-1">{report.siteName}</p>
|
||||
<p className="text-sm text-gray-500 mb-3">인정품목: {report.item}</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>
|
||||
|
||||
<div className={`flex items-center gap-2 p-2 rounded text-sm font-medium ${
|
||||
<div className={`flex items-center gap-1.5 sm:gap-2 p-1.5 sm:p-2 rounded text-xs sm:text-sm font-medium ${
|
||||
isSelected ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600'
|
||||
}`}>
|
||||
<Package size={16} />
|
||||
|
||||
@@ -27,15 +27,15 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||
<h2 className="font-bold text-gray-800 text-sm mb-4">
|
||||
<div className="bg-white rounded-lg p-3 sm:p-4 shadow-sm h-full flex flex-col overflow-hidden">
|
||||
<h2 className="font-bold text-gray-800 text-xs sm:text-sm mb-3 sm:mb-4">
|
||||
수주루트 목록{' '}
|
||||
{reportCode && (
|
||||
<span className="text-gray-400 font-normal ml-1">({reportCode})</span>
|
||||
)}
|
||||
</h2>
|
||||
|
||||
<div className="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 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-400 text-sm">
|
||||
{reportCode ? '수주루트가 없습니다.' : '품질관리서를 선택해주세요.'}
|
||||
@@ -51,7 +51,7 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
|
||||
<div key={route.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div
|
||||
onClick={() => handleClick(route)}
|
||||
className={`p-4 cursor-pointer flex justify-between items-start transition-colors ${
|
||||
className={`p-3 sm:p-4 cursor-pointer flex justify-between items-start transition-colors ${
|
||||
isSelected ? 'bg-green-50 border-b border-green-100' : 'bg-white hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
@@ -87,8 +87,8 @@ export const RouteList = ({ routes, selectedId, onSelect, onToggleItem, reportCo
|
||||
</div>
|
||||
|
||||
{isExpanded && route.subItems.length > 0 && (
|
||||
<div className="bg-white p-3 space-y-2">
|
||||
<div className="text-xs font-bold text-gray-600 mb-2 flex items-center gap-1">
|
||||
<div className="bg-white p-2 sm:p-3 space-y-1.5 sm:space-y-2">
|
||||
<div className="text-xs font-bold text-gray-600 mb-1.5 sm:mb-2 flex items-center gap-1">
|
||||
<MapPin size={10} /> 개소별 제품로트
|
||||
</div>
|
||||
{route.subItems.map((item) => (
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function QualityInspectionPage() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full min-h-[calc(100vh-64px)] lg:h-[calc(100vh-64px)] 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-hidden">
|
||||
{/* 헤더 (설정 버튼 포함) */}
|
||||
<Header
|
||||
rightContent={<SettingsButton onClick={() => setSettingsOpen(true)} />}
|
||||
@@ -272,9 +272,9 @@ export default function QualityInspectionPage() {
|
||||
|
||||
{activeDay === 1 ? (
|
||||
// ===== 1일차: 기준/매뉴얼 심사 =====
|
||||
<div className="flex-1 grid grid-cols-12 gap-4 lg:min-h-0">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:min-h-0">
|
||||
{/* 좌측: 점검표 항목 */}
|
||||
<div className={`col-span-12 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-0 lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentSection && displaySettings.showDocumentViewer
|
||||
? 'lg:col-span-3'
|
||||
: displaySettings.showDocumentSection || displaySettings.showDocumentViewer
|
||||
@@ -291,7 +291,7 @@ export default function QualityInspectionPage() {
|
||||
|
||||
{/* 중앙: 기준 문서화 */}
|
||||
{displaySettings.showDocumentSection && (
|
||||
<div className={`col-span-12 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-0 lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentViewer ? 'lg:col-span-4' : 'lg:col-span-8'
|
||||
}`}>
|
||||
<Day1DocumentSection
|
||||
@@ -306,7 +306,7 @@ export default function QualityInspectionPage() {
|
||||
|
||||
{/* 우측: 문서 뷰어 */}
|
||||
{displaySettings.showDocumentViewer && (
|
||||
<div className={`col-span-12 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-0 lg:h-full overflow-auto ${
|
||||
displaySettings.showDocumentSection ? 'lg:col-span-5' : 'lg:col-span-8'
|
||||
}`}>
|
||||
<Day1DocumentViewer document={selectedStandardDoc} />
|
||||
@@ -325,8 +325,8 @@ export default function QualityInspectionPage() {
|
||||
onSearchChange={handleSearchChange}
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-12 gap-6 lg:min-h-0">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<div className="flex-1 grid grid-cols-12 gap-3 sm:gap-4 lg:gap-6 lg:min-h-0">
|
||||
<div className="col-span-12 lg:col-span-3 min-h-[250px] sm:min-h-[300px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<ReportList
|
||||
reports={filteredReports}
|
||||
selectedId={selectedReport?.id || null}
|
||||
@@ -334,7 +334,7 @@ export default function QualityInspectionPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-4 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-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<RouteList
|
||||
routes={currentRoutes}
|
||||
selectedId={selectedRoute?.id || null}
|
||||
@@ -344,7 +344,7 @@ export default function QualityInspectionPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-12 lg:col-span-5 min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<div className="col-span-12 lg:col-span-5 min-h-[200px] sm:min-h-[250px] lg:min-h-0 lg:h-full overflow-auto lg:overflow-hidden">
|
||||
<DocumentList
|
||||
documents={currentDocuments}
|
||||
routeCode={selectedRoute?.code || null}
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
VENDOR_TYPE_LABELS,
|
||||
} from './types';
|
||||
import { createBadDebt, updateBadDebt, deleteBadDebt, addBadDebtMemo, deleteBadDebtMemo } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface BadDebtDetailProps {
|
||||
mode: 'view' | 'edit' | 'new';
|
||||
@@ -173,6 +174,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('저장 오류:', error);
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
@@ -200,6 +202,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('삭제 오류:', error);
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
@@ -243,6 +246,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
toast.error(result.error || '메모 추가에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('메모 추가 오류:', error);
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
@@ -275,6 +279,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
toast.error(result.error || '메모 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('메모 삭제 오류:', error);
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
TRANSACTION_TYPE_FILTER_OPTIONS,
|
||||
} from './types';
|
||||
import { getBankTransactionList, getBankTransactionSummary, getBankAccountOptions } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== Props =====
|
||||
interface BankTransactionInquiryProps {
|
||||
@@ -135,6 +136,7 @@ export function BankTransactionInquiry({
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[BankTransactionInquiry] loadData error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -254,9 +255,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
<ContentLoadingSpinner text="어음 정보를 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
USAGE_TYPE_OPTIONS,
|
||||
} from './types';
|
||||
import { getCardTransactionList, getCardTransactionSummary, bulkUpdateAccountCode } from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== Props =====
|
||||
interface CardTransactionInquiryProps {
|
||||
@@ -153,6 +154,7 @@ export function CardTransactionInquiry({
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[CardTransactionInquiry] loadData error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { ko } from 'date-fns/locale';
|
||||
import { Download, FileText, Loader2, RefreshCw, Calendar } from 'lucide-react';
|
||||
@@ -23,6 +23,7 @@ import type { NoteReceivableItem, DailyAccountItem } from './types';
|
||||
import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types';
|
||||
import { getNoteReceivables, getDailyAccounts, getDailyReportSummary, exportDailyReportExcel } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== Props 인터페이스 =====
|
||||
interface DailyReportProps {
|
||||
@@ -72,6 +73,7 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load daily report:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -79,9 +81,17 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
|
||||
}, [selectedDate]);
|
||||
|
||||
// ===== 초기 로드 및 날짜 변경시 재로드 =====
|
||||
const isInitialMount = useRef(true);
|
||||
const prevDateRef = useRef(selectedDate);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
// 초기 마운트 또는 날짜가 실제로 변경된 경우에만 로드
|
||||
if (isInitialMount.current || prevDateRef.current !== selectedDate) {
|
||||
isInitialMount.current = false;
|
||||
prevDateRef.current = selectedDate;
|
||||
loadData();
|
||||
}
|
||||
}, [selectedDate, loadData]);
|
||||
|
||||
// ===== 금액 포맷 =====
|
||||
const formatAmount = useCallback((amount: number) => {
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from './types';
|
||||
import { getReceivablesList, getReceivablesSummary, updateOverdueStatus, updateMemos, exportReceivablesExcel } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== Props 인터페이스 =====
|
||||
interface ReceivablesStatusProps {
|
||||
@@ -113,6 +114,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
setSummary(summaryResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load receivables:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
X,
|
||||
Send,
|
||||
FileText,
|
||||
Loader2,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -296,6 +296,15 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
return amount.toLocaleString();
|
||||
};
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<ContentLoadingSpinner text="매출 정보를 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 페이지 헤더 */}
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { FileText, Download, Pencil, Loader2, List } from 'lucide-react';
|
||||
import { FileText, Download, Pencil, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
@@ -19,6 +20,7 @@ import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import type { VendorLedgerDetail as VendorLedgerDetailType, TransactionEntry, VendorLedgerSummary } from './types';
|
||||
import { getVendorLedgerDetail, exportVendorLedgerDetailPdf } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface VendorLedgerDetailProps {
|
||||
vendorId: string;
|
||||
@@ -67,6 +69,7 @@ export function VendorLedgerDetail({
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VendorLedgerDetail] loadData error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -136,9 +139,7 @@ export function VendorLedgerDetail({
|
||||
if (isLoading && !vendorDetail) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
<ContentLoadingSpinner text="거래처 원장을 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -279,9 +280,7 @@ export function VendorLedgerDetail({
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
<ContentLoadingSpinner text="거래 내역을 불러오는 중..." />
|
||||
) : transactions.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-gray-500">
|
||||
거래 내역이 없습니다.
|
||||
|
||||
@@ -17,6 +17,7 @@ import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard
|
||||
import type { VendorLedgerItem, VendorLedgerSummary } from './types';
|
||||
import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== Props =====
|
||||
interface VendorLedgerProps {
|
||||
@@ -81,6 +82,7 @@ export function VendorLedger({
|
||||
setSummary(summaryResult.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VendorLedger] loadData error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Building2, Trash2, Plus, X } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { getClientById, createClient, updateClient, deleteClient } from './actions';
|
||||
@@ -382,6 +383,15 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
);
|
||||
};
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<ContentLoadingSpinner text="거래처 정보를 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
|
||||
@@ -69,6 +69,7 @@ import {
|
||||
APPROVAL_STATUS_LABELS,
|
||||
APPROVAL_STATUS_COLORS,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
interface InboxSummary {
|
||||
@@ -142,6 +143,7 @@ export function ApprovalBox() {
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(result.lastPage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load inbox:', error);
|
||||
toast.error('결재함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
@@ -155,6 +157,7 @@ export function ApprovalBox() {
|
||||
const result = await getInboxSummary();
|
||||
setSummary(result);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load summary:', error);
|
||||
}
|
||||
}, []);
|
||||
@@ -240,6 +243,7 @@ export function ApprovalBox() {
|
||||
toast.error(result.error || '승인 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Approve error:', error);
|
||||
toast.error('승인 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -277,6 +281,7 @@ export function ApprovalBox() {
|
||||
toast.error(result.error || '반려 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Reject error:', error);
|
||||
toast.error('반려 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Fragment } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
@@ -63,10 +63,7 @@ export function ExpenseEstimateForm({ data, onChange, isLoading }: ExpenseEstima
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">지출 예상 내역서 목록</h3>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">항목을 불러오는 중...</span>
|
||||
</div>
|
||||
<ContentLoadingSpinner text="항목을 불러오는 중..." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
import { useState, useCallback, useEffect, useTransition, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { FileText, Trash2, Send, Save, ArrowLeft, Eye, Loader2 } from 'lucide-react';
|
||||
import { FileText, Trash2, Send, Save, ArrowLeft, Eye } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getExpenseEstimateItems,
|
||||
@@ -38,6 +40,7 @@ import type {
|
||||
ExpenseReportData,
|
||||
ExpenseEstimateData,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// 초기 데이터 - SSR에서는 빈 문자열, 클라이언트에서 날짜 설정
|
||||
const getInitialBasicInfo = (): BasicInfo => ({
|
||||
@@ -137,6 +140,7 @@ export function DocumentCreate() {
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document:', error);
|
||||
toast.error('문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
@@ -186,6 +190,7 @@ export function DocumentCreate() {
|
||||
router.back();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load document for copy:', error);
|
||||
toast.error('원본 문서를 불러오는데 실패했습니다.');
|
||||
router.back();
|
||||
@@ -211,6 +216,7 @@ export function DocumentCreate() {
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load expense estimate items:', error);
|
||||
toast.error('비용견적서 항목을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
@@ -259,6 +265,7 @@ export function DocumentCreate() {
|
||||
toast.error(result.error || '문서 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('문서 삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -304,6 +311,7 @@ export function DocumentCreate() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('문서 상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -341,6 +349,7 @@ export function DocumentCreate() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Save draft error:', error);
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -463,9 +472,7 @@ export function DocumentCreate() {
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<ContentLoadingSpinner text="문서를 불러오는 중..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
SortOption,
|
||||
FilterOption,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import {
|
||||
SORT_OPTIONS,
|
||||
FILTER_OPTIONS,
|
||||
@@ -119,6 +120,7 @@ export function DraftBox() {
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(result.lastPage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load drafts:', error);
|
||||
toast.error('기안함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
@@ -132,6 +134,7 @@ export function DraftBox() {
|
||||
const result = await getDraftsSummary();
|
||||
setSummary(result);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load summary:', error);
|
||||
}
|
||||
}, []);
|
||||
@@ -186,6 +189,7 @@ export function DraftBox() {
|
||||
toast.error(result.error || '상신에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -208,6 +212,7 @@ export function DraftBox() {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -227,6 +232,7 @@ export function DraftBox() {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -306,6 +312,7 @@ export function DraftBox() {
|
||||
toast.error(result.error || '상신에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Submit error:', error);
|
||||
toast.error('상신 중 오류가 발생했습니다.');
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
READ_STATUS_LABELS,
|
||||
READ_STATUS_COLORS,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 통계 타입 =====
|
||||
interface ReferenceSummary {
|
||||
@@ -132,6 +133,7 @@ export function ReferenceBox() {
|
||||
setTotalCount(result.total);
|
||||
setTotalPages(result.lastPage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load references:', error);
|
||||
toast.error('참조함 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
@@ -145,6 +147,7 @@ export function ReferenceBox() {
|
||||
const result = await getReferenceSummary();
|
||||
setSummary(result);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load summary:', error);
|
||||
}
|
||||
}, []);
|
||||
@@ -220,6 +223,7 @@ export function ReferenceBox() {
|
||||
toast.error(result.error || '열람 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Mark read error:', error);
|
||||
toast.error('열람 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -250,6 +254,7 @@ export function ReferenceBox() {
|
||||
toast.error(result.error || '미열람 처리에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Mark unread error:', error);
|
||||
toast.error('미열람 처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
EyeOff,
|
||||
ArrowRight
|
||||
} from "lucide-react";
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
export function LoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -136,6 +137,7 @@ export function LoginPage() {
|
||||
// 대시보드로 이동
|
||||
router.push("/dashboard");
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
// 상세 에러 로깅
|
||||
console.error('❌ 로그인 실패:', err);
|
||||
if (err instanceof Error) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
FileText
|
||||
} from "lucide-react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
export function SignupPage() {
|
||||
const router = useRouter();
|
||||
@@ -188,6 +189,7 @@ export function SignupPage() {
|
||||
router.push("/login?registered=true");
|
||||
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('Signup error:', err);
|
||||
setError('Network error. Please try again.');
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -42,6 +42,7 @@ import { toast } from 'sonner';
|
||||
import { CommentSection } from '../CommentSection';
|
||||
import { deletePost } from '../actions';
|
||||
import type { Post, Comment } from '../types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface BoardDetailProps {
|
||||
post: Post;
|
||||
@@ -77,6 +78,7 @@ export function BoardDetail({ post, comments: initialComments, currentUserId }:
|
||||
toast.error(result.error || '게시글 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('게시글 삭제 오류:', error);
|
||||
toast.error('게시글 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
|
||||
@@ -49,6 +49,7 @@ import type { Post, Attachment } from '../types';
|
||||
import { createPost, updatePost } from '../actions';
|
||||
import { getBoards } from '../BoardManagement/actions';
|
||||
import type { Board } from '../BoardManagement/types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface BoardFormProps {
|
||||
mode: 'create' | 'edit';
|
||||
@@ -102,6 +103,7 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
setBoards(result.data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('게시판 목록 조회 오류:', error);
|
||||
toast.error('게시판 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
@@ -190,6 +192,7 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
toast.error(result?.error || '게시글 저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('게시글 저장 오류:', error);
|
||||
toast.error('게시글 저장에 실패했습니다.');
|
||||
} finally {
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { FileText, Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { FileText, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
@@ -36,6 +37,7 @@ import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard
|
||||
import { toast } from 'sonner';
|
||||
import type { Post, SortOption } from '../types';
|
||||
import { getBoards } from '../BoardManagement/actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { getPosts, getMyPosts, deletePost } from '../actions';
|
||||
import type { Board } from '../BoardManagement/types';
|
||||
|
||||
@@ -122,6 +124,7 @@ export function BoardList() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('게시글 조회 오류:', error);
|
||||
setPosts([]);
|
||||
} finally {
|
||||
@@ -404,11 +407,7 @@ export function BoardList() {
|
||||
|
||||
// ===== 로딩 상태 =====
|
||||
if (isLoading && posts.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="게시글을 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardList, Edit, Trash2, Plus, Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { getBoards, deleteBoard, deleteBoardsBulk } from './actions';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -349,11 +350,7 @@ export function BoardManagement() {
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="게시판 목록을 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
} from '@/components/ui/popover';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useState } from 'react';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface MenuBarProps {
|
||||
editor: Editor | null;
|
||||
@@ -73,6 +74,7 @@ export function MenuBar({ editor, onImageUpload }: MenuBarProps) {
|
||||
const url = await onImageUpload(file);
|
||||
editor.chain().focus().setImage({ src: url }).run();
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Image upload failed:', error);
|
||||
}
|
||||
} else {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -209,7 +209,7 @@ export const AmountCardItem = ({
|
||||
{card.label}
|
||||
</p>
|
||||
<p className={cn(
|
||||
"text-2xl md:text-3xl font-bold",
|
||||
"text-lg xs:text-xl md:text-2xl lg:text-3xl font-bold",
|
||||
card.isHighlighted && 'text-red-600'
|
||||
)}>
|
||||
{formatCardAmount(card.amount)}
|
||||
@@ -282,8 +282,8 @@ export const IssueCardItem = ({
|
||||
<p className={cn(
|
||||
"mt-2",
|
||||
typeof count === 'number'
|
||||
? "text-2xl md:text-3xl font-bold"
|
||||
: "text-xl md:text-2xl font-medium",
|
||||
? "text-lg xs:text-xl md:text-2xl lg:text-3xl font-bold"
|
||||
: "text-base xs:text-lg md:text-xl lg:text-2xl font-medium",
|
||||
isHighlighted ? 'text-white' : 'text-foreground'
|
||||
)}>
|
||||
{typeof count === 'number' ? `${count}건` : count}
|
||||
@@ -300,7 +300,7 @@ export const IssueCardItem = ({
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={cn(
|
||||
"w-10 h-10 md:w-12 md:h-12 opacity-15",
|
||||
"w-8 h-8 xs:w-10 xs:h-10 md:w-12 md:h-12 opacity-15",
|
||||
isHighlighted ? 'text-white' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
|
||||
350
src/components/business/CEODashboard/mockData.ts
Normal file
350
src/components/business/CEODashboard/mockData.ts
Normal file
@@ -0,0 +1,350 @@
|
||||
import type { CEODashboardData } from './types';
|
||||
|
||||
/**
|
||||
* CEO 대시보드 목데이터
|
||||
* TODO: API 연동 시 이 파일을 API 호출로 대체
|
||||
*/
|
||||
export const mockData: CEODashboardData = {
|
||||
todayIssue: [
|
||||
{ id: '1', label: '수주', count: 3, path: '/sales/order-management-sales', isHighlighted: false },
|
||||
{ id: '2', label: '채권 추심', count: 3, path: '/accounting/bad-debt-collection', isHighlighted: false },
|
||||
{ id: '3', label: '안전 재고', count: 3, path: '/material/stock-status', isHighlighted: true },
|
||||
{ id: '4', label: '세금 신고', count: '부가세 신고 D-15', path: '/accounting/tax', isHighlighted: false },
|
||||
{ id: '5', label: '신규 업체 등록', count: 3, path: '/accounting/vendors', isHighlighted: false },
|
||||
{ id: '6', label: '연차', count: 3, path: '/hr/vacation-management', isHighlighted: false },
|
||||
{ id: '7', label: '발주', count: 3, path: '/construction/order/order-management', isHighlighted: false },
|
||||
{ id: '8', label: '결재 요청', count: 3, path: '/approval/inbox', isHighlighted: false },
|
||||
],
|
||||
dailyReport: {
|
||||
date: '2026년 1월 5일 월요일',
|
||||
cards: [
|
||||
{ id: 'dr1', label: '현금성 자산 합계', amount: 3050000000 },
|
||||
{ id: 'dr2', label: '외국환(USD) 합계', amount: 11123000, currency: 'USD' },
|
||||
{ id: 'dr3', label: '입금 합계', amount: 1020000000 },
|
||||
{ id: 'dr4', label: '출금 합계', amount: 350000000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'dr-cp1',
|
||||
type: 'success',
|
||||
message: '어제 3.5억원 출금했습니다. 최근 7일 평균 대비 2배 이상으로 점검이 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '3.5억원 출금', color: 'red' },
|
||||
{ text: '점검이 필요', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dr-cp2',
|
||||
type: 'success',
|
||||
message: '어제 10.2억원이 입금되었습니다. 대한건설 선수금 입금이 주요 원인입니다.',
|
||||
highlights: [
|
||||
{ text: '10.2억원', color: 'green' },
|
||||
{ text: '입금', color: 'green' },
|
||||
{ text: '대한건설 선수금 입금', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'dr-cp3',
|
||||
type: 'success',
|
||||
message: '총 현금성 자산이 300.2억원입니다. 월 운영비용 대비 18개월분이 확보되어 안정적입니다.',
|
||||
highlights: [
|
||||
{ text: '18개월분', color: 'blue' },
|
||||
{ text: '안정적', color: 'blue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
monthlyExpense: {
|
||||
cards: [
|
||||
{ id: 'me1', label: '매입', amount: 3050000000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'me2', label: '카드', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'me3', label: '발행어음', amount: 30123000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'me4', label: '총 예상 지출 합계', amount: 350000000, previousLabel: '전월 대비 +10.5%' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'me-cp1',
|
||||
type: 'success',
|
||||
message: '이번 달 예상 지출이 전월 대비 15% 증가했습니다. 매입 비용 증가가 주요 원인입니다.',
|
||||
highlights: [
|
||||
{ text: '전월 대비 15% 증가', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'me-cp2',
|
||||
type: 'success',
|
||||
message: '이번 달 예상 지출이 예산을 12% 초과했습니다. 비용 항목별 점검이 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '예산을 12% 초과', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'me-cp3',
|
||||
type: 'success',
|
||||
message: '이번 달 예상 지출이 전월 대비 8% 감소했습니다. {계정과목명} 비용이 줄었습니다.',
|
||||
highlights: [
|
||||
{ text: '전월 대비 8% 감소', color: 'green' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
cardManagement: {
|
||||
warningBanner: '가지급금 인정이자 4.6%, 법인세 및 연말정산 시 대표자 종합세 가중 주의',
|
||||
cards: [
|
||||
{ id: 'cm1', label: '카드', amount: 30123000, previousLabel: '미정리 5건 (가지급금 예정)' },
|
||||
{ id: 'cm2', label: '가지급금', amount: 350000000, previousLabel: '전월 대비 +10.5%' },
|
||||
{ id: 'cm3', label: '법인세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' },
|
||||
{ id: 'cm4', label: '대표자 종합세 예상 가중', amount: 3123000, previousLabel: '추가 세금 +10.5%' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'cm-cp1',
|
||||
type: 'success',
|
||||
message: '법인카드 사용 총 850만원이 가지급금으로 전환되었습니다. 연 4.6% 인정이자가 발생합니다.',
|
||||
highlights: [
|
||||
{ text: '850만원', color: 'red' },
|
||||
{ text: '가지급금', color: 'red' },
|
||||
{ text: '연 4.6% 인정이자', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp2',
|
||||
type: 'success',
|
||||
message: '현재 가지급금 3.5원 × 4.6% = 연 약 1,400만원의 인정이자가 발생 중입니다.',
|
||||
highlights: [
|
||||
{ text: '연 약 1,400만원의 인정이자', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp3',
|
||||
type: 'success',
|
||||
message: '상품권/귀금속 등 접대비 불인정 항목 결제 감지. 가지급금 처리 예정입니다.',
|
||||
highlights: [
|
||||
{ text: '불인정 항목 결제 감지', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cm-cp4',
|
||||
type: 'success',
|
||||
message: '주말 카드 사용 100만원 결제 감지. 업무관련성 소명이 어려울 수 있으니 기록을 남겨주세요.',
|
||||
highlights: [
|
||||
{ text: '주말 카드 사용 100만원 결제 감지', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
entertainment: {
|
||||
cards: [
|
||||
{ id: 'et1', label: '매출', amount: 30530000000 },
|
||||
{ id: 'et2', label: '{1사분기} 접대비 총 한도', amount: 40123000 },
|
||||
{ id: 'et3', label: '{1사분기} 접대비 잔여한도', amount: 30123000 },
|
||||
{ id: 'et4', label: '{1사분기} 접대비 사용금액', amount: 10000000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'et-cp1',
|
||||
type: 'success',
|
||||
message: '{1사분기} 접대비 사용 1,000만원 / 한도 4,012만원 (75%). 여유 있게 운영 중입니다.',
|
||||
highlights: [
|
||||
{ text: '1,000만원', color: 'green' },
|
||||
{ text: '4,012만원 (75%)', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'et-cp2',
|
||||
type: 'success',
|
||||
message: '접대비 한도 85% 도달. 잔여 한도 600만원입니다. 사용 계획을 점검해 주세요.',
|
||||
highlights: [
|
||||
{ text: '잔여 한도 600만원', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'et-cp3',
|
||||
type: 'error',
|
||||
message: '접대비 한도 초과 320만원 발생. 초과분은 손금불산입되어 법인세 부담이 증가합니다.',
|
||||
highlights: [
|
||||
{ text: '320만원 발생', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'et-cp4',
|
||||
type: 'error',
|
||||
message: '접대비 사용 중 3건(45만원)의 거래처 정보가 누락되었습니다. 기록을 보완해 주세요.',
|
||||
highlights: [
|
||||
{ text: '3건(45만원)', color: 'red' },
|
||||
{ text: '거래처 정보가 누락', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
welfare: {
|
||||
cards: [
|
||||
{ id: 'wf1', label: '당해년도 복리후생비 한도', amount: 30123000 },
|
||||
{ id: 'wf2', label: '{1사분기} 복리후생비 총 한도', amount: 10123000 },
|
||||
{ id: 'wf3', label: '{1사분기} 복리후생비 잔여한도', amount: 5123000 },
|
||||
{ id: 'wf4', label: '{1사분기} 복리후생비 사용금액', amount: 5123000 },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'wf-cp1',
|
||||
type: 'success',
|
||||
message: '1인당 월 복리후생비 20만원. 업계 평균(15~25만원) 내 정상 운영 중입니다.',
|
||||
highlights: [
|
||||
{ text: '1인당 월 복리후생비 20만원', color: 'green' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'wf-cp2',
|
||||
type: 'error',
|
||||
message: '식대가 월 25만원으로 비과세 한도(20만원)를 초과했습니다. 초과분은 근로소득 과세됩니다.',
|
||||
highlights: [
|
||||
{ text: '식대가 월 25만원으로', color: 'red' },
|
||||
{ text: '초과', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
receivable: {
|
||||
cards: [
|
||||
{
|
||||
id: 'rv1',
|
||||
label: '누적 미수금',
|
||||
amount: 30123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 60123000 },
|
||||
{ label: '입금', value: 30000000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv2',
|
||||
label: '당월 미수금',
|
||||
amount: 10123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 60123000 },
|
||||
{ label: '입금', value: 30000000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv3',
|
||||
label: '회사명',
|
||||
amount: 3123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 6123000 },
|
||||
{ label: '입금', value: 3000000 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv4',
|
||||
label: '회사명',
|
||||
amount: 2123000,
|
||||
subItems: [
|
||||
{ label: '매출', value: 6123000 },
|
||||
{ label: '입금', value: 3000000 },
|
||||
],
|
||||
},
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'rv-cp1',
|
||||
type: 'success',
|
||||
message: '90일 이상 장기 미수금 3건(2,500만원) 발생. 회수 조치가 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '90일 이상 장기 미수금 3건(2,500만원) 발생', color: 'red' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'rv-cp2',
|
||||
type: 'success',
|
||||
message: '(주)대한전자 미수금 1,500만원으로 전체의 35%를 차지합니다. 리스크 분산이 필요합니다.',
|
||||
highlights: [
|
||||
{ text: '(주)대한전자 미수금 1,500만원으로 전체의 35%를', color: 'red' },
|
||||
],
|
||||
},
|
||||
],
|
||||
detailButtonPath: '/accounting/receivables-status',
|
||||
},
|
||||
debtCollection: {
|
||||
cards: [
|
||||
{ id: 'dc1', label: '누적 악성채권', amount: 350000000, subLabel: '25건' },
|
||||
{ id: 'dc2', label: '추심중', amount: 30123000, subLabel: '12건' },
|
||||
{ id: 'dc3', label: '법적조치', amount: 3123000, subLabel: '3건' },
|
||||
{ id: 'dc4', label: '회수완료', amount: 280000000, subLabel: '10건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'dc-cp1',
|
||||
type: 'success',
|
||||
message: '(주)대한전자 건 지급명령 신청 완료. 법원 결정까지 약 2주 소요 예정입니다.',
|
||||
highlights: [{ text: '(주)대한전자 건 지급명령 신청 완료.', color: 'red' }],
|
||||
},
|
||||
{
|
||||
id: 'dc-cp2',
|
||||
type: 'success',
|
||||
message: '(주)삼성테크 건 회수 불가 판정. 대손 처리 검토가 필요합니다.',
|
||||
highlights: [{ text: '(주)삼성테크 건 회수 불가 판정.', color: 'red' }],
|
||||
},
|
||||
],
|
||||
detailButtonPath: '/accounting/bad-debt-collection',
|
||||
},
|
||||
vat: {
|
||||
cards: [
|
||||
{ id: 'vat1', label: '매출세액', amount: 3050000000 },
|
||||
{ id: 'vat2', label: '매입세액', amount: 2050000000 },
|
||||
{ id: 'vat3', label: '예상 납부세액', amount: 110000000 },
|
||||
{ id: 'vat4', label: '세금계산서 미발행', amount: 3, unit: '건' },
|
||||
],
|
||||
checkPoints: [
|
||||
{
|
||||
id: 'vat-cp1',
|
||||
type: 'success',
|
||||
message: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다. 설비투자에 따른 매입세액 증가가 주요 원인입니다.',
|
||||
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 환급세액은 5,200,000원입니다.', color: 'red' }],
|
||||
},
|
||||
{
|
||||
id: 'vat-cp2',
|
||||
type: 'success',
|
||||
message: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다. 전기 대비 12.9% 증가했으며, 이는 매출 증가에 따른 정상적인 증가로 판단됩니다.',
|
||||
highlights: [{ text: '2026년 1기 예정신고 기준, 예상 납부세액은 110,100,000원입니다.', color: 'red' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
calendarSchedules: [
|
||||
{
|
||||
id: 'sch1',
|
||||
title: '제목',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-01-04',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
type: 'schedule',
|
||||
department: '부서명',
|
||||
},
|
||||
{
|
||||
id: 'sch2',
|
||||
title: '제목',
|
||||
startDate: '2026-01-06',
|
||||
endDate: '2026-01-06',
|
||||
type: 'schedule',
|
||||
personName: '홍길동',
|
||||
},
|
||||
{
|
||||
id: 'sch3',
|
||||
title: '제목',
|
||||
startDate: '2026-01-06',
|
||||
endDate: '2026-01-06',
|
||||
startTime: '09:00',
|
||||
endTime: '12:00',
|
||||
type: 'order',
|
||||
department: '부서명',
|
||||
},
|
||||
{
|
||||
id: 'sch4',
|
||||
title: '제목',
|
||||
startDate: '2026-01-06',
|
||||
endDate: '2026-01-06',
|
||||
startTime: '12:35',
|
||||
type: 'construction',
|
||||
personName: '홍길동',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 카드/가지급금 관리 모달 설정
|
||||
* cm1: 카드 사용 상세
|
||||
* cm2: 가지급금 상세
|
||||
* cm3: 법인세 예상 가중 상세
|
||||
* cm4: 대표자 종합소득세 예상 가중 상세
|
||||
*/
|
||||
export function getCardManagementModalConfig(cardId: string): DetailModalConfig | null {
|
||||
const configs: Record<string, DetailModalConfig> = {
|
||||
cm1: {
|
||||
title: '카드 사용 상세',
|
||||
summaryCards: [
|
||||
{ label: '당월 카드 사용', value: 30123000, unit: '원' },
|
||||
{ label: '전월 대비', value: '+10.5%', isComparison: true, isPositive: true },
|
||||
{ label: '미정리 건수', value: '5건' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 카드 사용 추이',
|
||||
data: [
|
||||
{ name: '7월', value: 28000000 },
|
||||
{ name: '8월', value: 32000000 },
|
||||
{ name: '9월', value: 27000000 },
|
||||
{ name: '10월', value: 35000000 },
|
||||
{ name: '11월', value: 29000000 },
|
||||
{ name: '12월', value: 30123000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '사용자별 카드 사용 비율',
|
||||
data: [
|
||||
{ name: '대표이사', value: 15000000, percentage: 50, color: '#60A5FA' },
|
||||
{ name: '경영지원팀', value: 9000000, percentage: 30, color: '#34D399' },
|
||||
{ name: '영업팀', value: 6123000, percentage: 20, color: '#FBBF24' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '카드 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'date', label: '사용일시', align: 'center', format: 'date' },
|
||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-05 18:30', store: '스타벅스 강남점', amount: 45000, usageType: '복리후생비' },
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-04 12:15', store: '한식당', amount: 350000, usageType: '접대비' },
|
||||
{ cardName: '법인카드2', user: '경영지원팀', date: '2026-01-03 14:20', store: '오피스디포', amount: 125000, usageType: '소모품비' },
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2026-01-02 19:45', store: '골프장', amount: 850000, usageType: '미설정' },
|
||||
{ cardName: '법인카드3', user: '영업팀', date: '2026-01-02 11:30', store: 'GS칼텍스', amount: 80000, usageType: '교통비' },
|
||||
{ cardName: '법인카드2', user: '경영지원팀', date: '2026-01-01 16:00', store: '이마트', amount: 230000, usageType: '미설정' },
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2025-12-30 20:30', store: '백화점', amount: 1500000, usageType: '미설정' },
|
||||
{ cardName: '법인카드3', user: '영업팀', date: '2025-12-29 09:15', store: '커피빈', amount: 32000, usageType: '복리후생비' },
|
||||
{ cardName: '법인카드2', user: '경영지원팀', date: '2025-12-28 13:45', store: '문구점', amount: 55000, usageType: '소모품비' },
|
||||
{ cardName: '법인카드1', user: '대표이사', date: '2025-12-27 21:00', store: '호텔', amount: 450000, usageType: '미설정' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'user',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '대표이사', label: '대표이사' },
|
||||
{ value: '경영지원팀', label: '경영지원팀' },
|
||||
{ value: '영업팀', label: '영업팀' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'usageType',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '미설정', label: '미설정' },
|
||||
{ value: '복리후생비', label: '복리후생비' },
|
||||
{ value: '접대비', label: '접대비' },
|
||||
{ value: '소모품비', label: '소모품비' },
|
||||
{ value: '교통비', label: '교통비' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 30123000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
cm2: {
|
||||
title: '가지급금 상세',
|
||||
summaryCards: [
|
||||
{ label: '가지급금', value: '4.5억원' },
|
||||
{ label: '인정이자 4.6%', value: 6000000, unit: '원' },
|
||||
{ label: '미정정', value: '10건' },
|
||||
],
|
||||
table: {
|
||||
title: '가지급금 관련 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'date', label: '발생일시', align: 'center' },
|
||||
{ key: 'target', label: '대상', align: 'center' },
|
||||
{ key: 'category', label: '구분', align: 'center' },
|
||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||
{ key: 'status', label: '상태', align: 'center', highlightValue: '미정정' },
|
||||
{ key: 'content', label: '내용', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '미정정', content: '미정정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접비(미정리)', content: '접대비 불인정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미정정', content: '접대비 불인정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '계좌명', amount: 1000000, status: '미정정', content: '미정정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '-', amount: 1000000, status: '미정정', content: '미정정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '접대비', content: '접대비 불인정' },
|
||||
{ date: '2025-12-12 12:12', target: '홍길동', category: '카드명', amount: 1000000, status: '-', content: '복리후생비, 주말/심야 카드 사용' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'target',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '카드명', label: '카드명' },
|
||||
{ value: '계좌명', label: '계좌명' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
cm3: {
|
||||
title: '법인세 예상 가중 상세',
|
||||
summaryCards: [
|
||||
{ label: '법인세 예상 증가', value: 3123000, unit: '원' },
|
||||
{ label: '인정 이자', value: 6000000, unit: '원' },
|
||||
{ label: '가지급금', value: '4.5억원' },
|
||||
{ label: '인정이자', value: 6000000, unit: '원' },
|
||||
],
|
||||
comparisonSection: {
|
||||
leftBox: {
|
||||
title: '없을때 법인세',
|
||||
items: [
|
||||
{ label: '과세표준', value: '3억원' },
|
||||
{ label: '법인세', value: 50970000, unit: '원' },
|
||||
],
|
||||
borderColor: 'orange',
|
||||
},
|
||||
rightBox: {
|
||||
title: '있을때 법인세',
|
||||
items: [
|
||||
{ label: '과세표준', value: '3.06억원' },
|
||||
{ label: '법인세', value: 54093000, unit: '원' },
|
||||
],
|
||||
borderColor: 'blue',
|
||||
},
|
||||
vsLabel: '법인세 예상 증가',
|
||||
vsValue: 3123000,
|
||||
vsSubLabel: '법인 세율 -12.5%',
|
||||
},
|
||||
referenceTable: {
|
||||
title: '법인세 과세표준 (2024년 기준)',
|
||||
columns: [
|
||||
{ key: 'bracket', label: '과세표준', align: 'left' },
|
||||
{ key: 'rate', label: '세율', align: 'center' },
|
||||
{ key: 'formula', label: '계산식', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ bracket: '2억원 이하', rate: '9%', formula: '과세표준 × 9%' },
|
||||
{ bracket: '2억원 초과 ~ 200억원 이하', rate: '19%', formula: '1,800만원 + (2억원 초과분 × 19%)' },
|
||||
{ bracket: '200억원 초과 ~ 3,000억원 이하', rate: '21%', formula: '37.62억원 + (200억원 초과분 × 21%)' },
|
||||
{ bracket: '3,000억원 초과', rate: '24%', formula: '625.62억원 + (3,000억원 초과분 × 24%)' },
|
||||
],
|
||||
},
|
||||
},
|
||||
cm4: {
|
||||
title: '대표자 종합소득세 예상 가중 상세',
|
||||
summaryCards: [
|
||||
{ label: '대표자 종합세 예상 가중', value: 3123000, unit: '원' },
|
||||
{ label: '추가 세금', value: '+12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '가지급금', value: '4.5억원' },
|
||||
{ label: '인정이자 4.6%', value: 6000000, unit: '원' },
|
||||
],
|
||||
comparisonSection: {
|
||||
leftBox: {
|
||||
title: '가지급금 인정이자가 반영된 종합소득세',
|
||||
items: [
|
||||
{ label: '현재 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
|
||||
{ label: '현재 적용 세율', value: '19%' },
|
||||
{ label: '현재 예상 세액', value: 10000000, unit: '원' },
|
||||
],
|
||||
borderColor: 'orange',
|
||||
},
|
||||
rightBox: {
|
||||
title: '가지급금 인정이자가 정리된 종합소득세',
|
||||
items: [
|
||||
{ label: '가지급금 정리 시 예상 과세표준 (근로소득+상여)', value: 6000000, unit: '원' },
|
||||
{ label: '가지급금 정리 시 적용 세율', value: '19%' },
|
||||
{ label: '가지급금 정리 시 예상 세액', value: 10000000, unit: '원' },
|
||||
],
|
||||
borderColor: 'blue',
|
||||
},
|
||||
vsLabel: '종합소득세 예상 절감',
|
||||
vsValue: 3123000,
|
||||
vsSubLabel: '감소 세금 -12.5%',
|
||||
vsBreakdown: [
|
||||
{ label: '종합소득세', value: -2000000, unit: '원' },
|
||||
{ label: '지방소득세', value: -200000, unit: '원' },
|
||||
{ label: '4대 보험', value: -1000000, unit: '원' },
|
||||
],
|
||||
},
|
||||
referenceTable: {
|
||||
title: '종합소득세 과세표준 (2024년 기준)',
|
||||
columns: [
|
||||
{ key: 'bracket', label: '과세표준', align: 'left' },
|
||||
{ key: 'rate', label: '세율', align: 'center' },
|
||||
{ key: 'deduction', label: '누진공제', align: 'right' },
|
||||
{ key: 'formula', label: '계산식', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ bracket: '1,400만원 이하', rate: '6%', deduction: '-', formula: '과세표준 × 6%' },
|
||||
{ bracket: '1,400만원 초과 ~ 5,000만원 이하', rate: '15%', deduction: '126만원', formula: '과세표준 × 15% - 126만원' },
|
||||
{ bracket: '5,000만원 초과 ~ 8,800만원 이하', rate: '24%', deduction: '576만원', formula: '과세표준 × 24% - 576만원' },
|
||||
{ bracket: '8,800만원 초과 ~ 1.5억원 이하', rate: '35%', deduction: '1,544만원', formula: '과세표준 × 35% - 1,544만원' },
|
||||
{ bracket: '1.5억원 초과 ~ 3억원 이하', rate: '38%', deduction: '1,994만원', formula: '과세표준 × 38% - 1,994만원' },
|
||||
{ bracket: '3억원 초과 ~ 5억원 이하', rate: '40%', deduction: '2,594만원', formula: '과세표준 × 40% - 2,594만원' },
|
||||
{ bracket: '5억원 초과 ~ 10억원 이하', rate: '42%', deduction: '3,594만원', formula: '과세표준 × 42% - 3,594만원' },
|
||||
{ bracket: '10억원 초과', rate: '45%', deduction: '6,594만원', formula: '과세표준 × 45% - 6,594만원' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return configs[cardId] || null;
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 접대비 상세 공통 모달 config (et2, et3, et4 공통)
|
||||
*/
|
||||
const entertainmentDetailConfig: DetailModalConfig = {
|
||||
title: '접대비 상세',
|
||||
summaryCards: [
|
||||
// 첫 번째 줄: 당해년도
|
||||
{ label: '당해년도 접대비 총한도', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 접대비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 접대비 사용금액', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 접대비 사용잔액', value: 0, unit: '원' },
|
||||
// 두 번째 줄: 분기별
|
||||
{ label: '1사분기 접대비 총한도', value: 3123000, unit: '원' },
|
||||
{ label: '1사분기 접대비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '1사분기 접대비 사용금액', value: 6000000, unit: '원' },
|
||||
{ label: '1사분기 접대비 초과금액', value: 6000000, unit: '원' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 접대비 사용 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 3500000 },
|
||||
{ name: '2월', value: 4200000 },
|
||||
{ name: '3월', value: 2300000 },
|
||||
{ name: '4월', value: 3800000 },
|
||||
{ name: '5월', value: 4500000 },
|
||||
{ name: '6월', value: 3200000 },
|
||||
{ name: '7월', value: 2800000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '사용자별 접대비 사용 비율',
|
||||
data: [
|
||||
{ name: '홍길동', value: 15000000, percentage: 53, color: '#60A5FA' },
|
||||
{ name: '김철수', value: 10000000, percentage: 31, color: '#34D399' },
|
||||
{ name: '이영희', value: 10000000, percentage: 10, color: '#FBBF24' },
|
||||
{ name: '기타', value: 2000000, percentage: 6, color: '#F87171' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '월별 접대비 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'useDate', label: '사용일시', align: 'center', format: 'date' },
|
||||
{ key: 'transDate', label: '거래일시', align: 'center', format: 'date' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'purpose', label: '사용용도', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-10-14 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
{ cardName: '카드명', user: '홍길동', useDate: '2025-12-12 12:12', transDate: '가맹점명', amount: 1000000, purpose: '사용용도' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'user',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
{ value: '김철수', label: '김철수' },
|
||||
{ value: '이영희', label: '이영희' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 11000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
// 접대비 손금한도 계산 - 기본한도 / 수입금액별 추가한도
|
||||
referenceTables: [
|
||||
{
|
||||
title: '접대비 손금한도 계산 - 기본한도',
|
||||
columns: [
|
||||
{ key: 'type', label: '구분', align: 'left' },
|
||||
{ key: 'limit', label: '기본한도', align: 'right' },
|
||||
],
|
||||
data: [
|
||||
{ type: '일반법인', limit: '3,600만원 (연 1,200만원)' },
|
||||
{ type: '중소기업', limit: '5,400만원 (연 3,600만원)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '수입금액별 추가한도',
|
||||
columns: [
|
||||
{ key: 'range', label: '수입금액', align: 'left' },
|
||||
{ key: 'rate', label: '적용률', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ range: '100억원 이하', rate: '0.3%' },
|
||||
{ range: '100억원 초과 ~ 500억원 이하', rate: '0.2%' },
|
||||
{ range: '500억원 초과', rate: '0.03%' },
|
||||
],
|
||||
},
|
||||
],
|
||||
// 접대비 계산
|
||||
calculationCards: {
|
||||
title: '접대비 계산',
|
||||
cards: [
|
||||
{ label: '기본한도', value: 36000000 },
|
||||
{ label: '추가한도', value: 91170000, operator: '+' },
|
||||
{ label: '접대비 손금한도', value: 127170000, operator: '=' },
|
||||
],
|
||||
},
|
||||
// 접대비 현황 (분기별)
|
||||
quarterlyTable: {
|
||||
title: '접대비 현황',
|
||||
rows: [
|
||||
{ label: '접대비 한도', q1: 31792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 127170000 },
|
||||
{ label: '접대비 사용', q1: 10000000, q2: 0, q3: 0, q4: 0, total: 10000000 },
|
||||
{ label: '접대비 잔여', q1: 21792500, q2: 31792500, q3: 31792500, q4: 31792500, total: 117170000 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 접대비 현황 모달 설정
|
||||
* et1: 당해 매출 상세
|
||||
* et2, et3, et4: 접대비 상세 (공통)
|
||||
*/
|
||||
export function getEntertainmentModalConfig(cardId: string): DetailModalConfig | null {
|
||||
const configs: Record<string, DetailModalConfig> = {
|
||||
et1: {
|
||||
title: '당해 매출 상세',
|
||||
summaryCards: [
|
||||
{ label: '당해년도 매출', value: 600000000, unit: '원' },
|
||||
{ label: '전년 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '당월 매출', value: 6000000, unit: '원' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 매출 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 85000000 },
|
||||
{ name: '2월', value: 92000000 },
|
||||
{ name: '3월', value: 78000000 },
|
||||
{ name: '4월', value: 95000000 },
|
||||
{ name: '5월', value: 88000000 },
|
||||
{ name: '6월', value: 102000000 },
|
||||
{ name: '7월', value: 60000000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
horizontalBarChart: {
|
||||
title: '당해년도 거래처별 매출',
|
||||
data: [
|
||||
{ name: '(주)세우', value: 120000000 },
|
||||
{ name: '대한건설', value: 95000000 },
|
||||
{ name: '삼성테크', value: 78000000 },
|
||||
{ name: '현대상사', value: 65000000 },
|
||||
{ name: '기타', value: 42000000 },
|
||||
],
|
||||
color: '#60A5FA',
|
||||
},
|
||||
table: {
|
||||
title: '일별 매출 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'date', label: '매출일', align: 'center', format: 'date' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'amount', label: '매출금액', align: 'right', format: 'currency' },
|
||||
{ key: 'type', label: '매출유형', align: 'center', highlightValue: '미설정' },
|
||||
],
|
||||
data: [
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부품 매출' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '공사 매출' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '상품 매출' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'type',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '상품 매출', label: '상품 매출' },
|
||||
{ value: '부품 매출', label: '부품 매출' },
|
||||
{ value: '공사 매출', label: '공사 매출' },
|
||||
{ value: '임대 수익', label: '임대 수익' },
|
||||
{ value: '기타 매출', label: '기타 매출' },
|
||||
{ value: '미설정', label: '미설정' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
// et2, et3, et4는 모두 동일한 접대비 상세 모달
|
||||
et2: entertainmentDetailConfig,
|
||||
et3: entertainmentDetailConfig,
|
||||
et4: entertainmentDetailConfig,
|
||||
};
|
||||
|
||||
return configs[cardId] || null;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { getMonthlyExpenseModalConfig } from './monthlyExpenseConfigs';
|
||||
export { getCardManagementModalConfig } from './cardManagementConfigs';
|
||||
export { getEntertainmentModalConfig } from './entertainmentConfigs';
|
||||
export { getWelfareModalConfig } from './welfareConfigs';
|
||||
export { getVatModalConfig } from './vatConfigs';
|
||||
@@ -0,0 +1,317 @@
|
||||
import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 당월 예상 지출 모달 설정
|
||||
*/
|
||||
export function getMonthlyExpenseModalConfig(cardId: string): DetailModalConfig | null {
|
||||
const configs: Record<string, DetailModalConfig> = {
|
||||
me1: {
|
||||
title: '당월 매입 상세',
|
||||
summaryCards: [
|
||||
{ label: '당월 매입', value: 3123000, unit: '원' },
|
||||
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 매입 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 45000000 },
|
||||
{ name: '2월', value: 52000000 },
|
||||
{ name: '3월', value: 48000000 },
|
||||
{ name: '4월', value: 61000000 },
|
||||
{ name: '5월', value: 55000000 },
|
||||
{ name: '6월', value: 58000000 },
|
||||
{ name: '7월', value: 50000000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '자재 유형별 구매 비율',
|
||||
data: [
|
||||
{ name: '원자재', value: 55000000, percentage: 55, color: '#60A5FA' },
|
||||
{ name: '부자재', value: 35000000, percentage: 35, color: '#34D399' },
|
||||
{ name: '포장재', value: 10000000, percentage: 10, color: '#FBBF24' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '일별 매입 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'date', label: '매입일', align: 'center', format: 'date' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'amount', label: '매입금액', align: 'right', format: 'currency' },
|
||||
{ key: 'type', label: '매입유형', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '부재료매입' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '미설정' },
|
||||
{ date: '2025-12-12', vendor: '회사명', amount: 11000000, type: '원재료매입' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'type',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '원재료매입', label: '원재료매입' },
|
||||
{ value: '부재료매입', label: '부재료매입' },
|
||||
{ value: '미설정', label: '미설정' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '오래된순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
me2: {
|
||||
title: '당월 카드 상세',
|
||||
summaryCards: [
|
||||
{ label: '당월 카드 사용', value: 6000000, unit: '원' },
|
||||
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '이용건', value: '10건' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 카드 사용 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 4500000 },
|
||||
{ name: '2월', value: 5200000 },
|
||||
{ name: '3월', value: 4800000 },
|
||||
{ name: '4월', value: 6100000 },
|
||||
{ name: '5월', value: 5500000 },
|
||||
{ name: '6월', value: 5800000 },
|
||||
{ name: '7월', value: 6000000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '사용자별 카드 사용 비율',
|
||||
data: [
|
||||
{ name: '홍길동', value: 55000000, percentage: 55, color: '#60A5FA' },
|
||||
{ name: '김길동', value: 35000000, percentage: 35, color: '#34D399' },
|
||||
{ name: '이길동', value: 10000000, percentage: 10, color: '#FBBF24' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '일별 카드 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'date', label: '사용일시', align: 'center', format: 'date' },
|
||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'usageType', label: '사용유형', align: 'center', highlightValue: '미설정' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '복리후생비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-11 14:30', store: '가맹점명', amount: 1000000, usageType: '접대비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-10 09:45', store: '가맹점명', amount: 1000000, usageType: '미설정' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-09 18:20', store: '가맹점명', amount: 1000000, usageType: '미설정' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-08 11:15', store: '가맹점명', amount: 1000000, usageType: '미설정' },
|
||||
{ cardName: '카드명', user: '김길동', date: '2025-12-07 16:40', store: '가맹점명', amount: 5000000, usageType: '교통비' },
|
||||
{ cardName: '카드명', user: '이길동', date: '2025-12-06 10:30', store: '가맹점명', amount: 1000000, usageType: '소모품비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-05 13:25', store: '스타벅스', amount: 45000, usageType: '복리후생비' },
|
||||
{ cardName: '카드명', user: '김길동', date: '2025-12-04 19:50', store: '주유소', amount: 80000, usageType: '교통비' },
|
||||
{ cardName: '카드명', user: '이길동', date: '2025-12-03 08:10', store: '편의점', amount: 15000, usageType: '미설정' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-02 12:00', store: '식당', amount: 250000, usageType: '접대비' },
|
||||
{ cardName: '카드명', user: '김길동', date: '2025-12-01 15:35', store: '문구점', amount: 35000, usageType: '소모품비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-11-30 17:20', store: '호텔', amount: 350000, usageType: '미설정' },
|
||||
{ cardName: '카드명', user: '이길동', date: '2025-11-29 09:00', store: '택시', amount: 25000, usageType: '교통비' },
|
||||
{ cardName: '카드명', user: '김길동', date: '2025-11-28 14:15', store: '커피숍', amount: 32000, usageType: '복리후생비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-11-27 11:45', store: '마트', amount: 180000, usageType: '소모품비' },
|
||||
{ cardName: '카드명', user: '이길동', date: '2025-11-26 16:30', store: '서점', amount: 45000, usageType: '미설정' },
|
||||
{ cardName: '카드명', user: '김길동', date: '2025-11-25 10:20', store: '식당', amount: 120000, usageType: '접대비' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'user',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '홍길동', label: '홍길동' },
|
||||
{ value: '김길동', label: '김길동' },
|
||||
{ value: '이길동', label: '이길동' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 11000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
me3: {
|
||||
title: '당월 발행어음 상세',
|
||||
summaryCards: [
|
||||
{ label: '당월 발행어음 사용', value: 3123000, unit: '원' },
|
||||
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 발행어음 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 2000000 },
|
||||
{ name: '2월', value: 2500000 },
|
||||
{ name: '3월', value: 2200000 },
|
||||
{ name: '4월', value: 2800000 },
|
||||
{ name: '5월', value: 2600000 },
|
||||
{ name: '6월', value: 3000000 },
|
||||
{ name: '7월', value: 3123000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
horizontalBarChart: {
|
||||
title: '당월 거래처별 발행어음',
|
||||
data: [
|
||||
{ name: '거래처1', value: 50000000 },
|
||||
{ name: '거래처2', value: 35000000 },
|
||||
{ name: '거래처3', value: 20000000 },
|
||||
{ name: '거래처4', value: 6000000 },
|
||||
],
|
||||
color: '#60A5FA',
|
||||
},
|
||||
table: {
|
||||
title: '일별 발행어음 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'issueDate', label: '발행일', align: 'center', format: 'date' },
|
||||
{ key: 'dueDate', label: '만기일', align: 'center', format: 'date' },
|
||||
{ key: 'amount', label: '금액', align: 'right', format: 'currency' },
|
||||
{ key: 'status', label: '상태', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '보관중' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 1000000, status: '만기임박' },
|
||||
{ vendor: '회사명', issueDate: '2025-12-12', dueDate: '2025-12-12', amount: 105000000, status: '보관중' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'vendor',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '회사명', label: '회사명' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '보관중', label: '보관중' },
|
||||
{ value: '만기임박', label: '만기임박' },
|
||||
{ value: '만기경과', label: '만기경과' },
|
||||
{ value: '결제완료', label: '결제완료' },
|
||||
{ value: '부도', label: '부도' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
},
|
||||
me4: {
|
||||
title: '당월 지출 예상 상세',
|
||||
summaryCards: [
|
||||
{ label: '당월 지출 예상', value: 6000000, unit: '원' },
|
||||
{ label: '전월 대비', value: '-12.5%', isComparison: true, isPositive: false },
|
||||
{ label: '총 계좌 잔액', value: 10000000, unit: '원' },
|
||||
],
|
||||
table: {
|
||||
title: '당월 지출 승인 내역서',
|
||||
columns: [
|
||||
{ key: 'paymentDate', label: '예상 지급일', align: 'center' },
|
||||
{ key: 'item', label: '항목', align: 'left' },
|
||||
{ key: 'amount', label: '지출금액', align: 'right', format: 'currency', highlightColor: 'red' },
|
||||
{ key: 'vendor', label: '거래처', align: 'center' },
|
||||
{ key: 'account', label: '계좌', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '(발행 어음) 123123123', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '거래처명 12월분', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '품의 사유...', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
{ paymentDate: '2025-12-12', item: '적요 내용', amount: 1000000, vendor: '회사명', account: '국민은행 1234' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'vendor',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '회사명', label: '회사명' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '2025/12 계',
|
||||
totalValue: 6000000,
|
||||
totalColumnKey: 'amount',
|
||||
footerSummary: [
|
||||
{ label: '지출 합계', value: 6000000 },
|
||||
{ label: '계좌 잔액', value: 10000000 },
|
||||
{ label: '최종 차액', value: 4000000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return configs[cardId] || null;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 부가세 모달 설정
|
||||
* 모든 카드가 동일한 상세 모달
|
||||
*/
|
||||
export function getVatModalConfig(): DetailModalConfig {
|
||||
return {
|
||||
title: '예상 납부세액',
|
||||
summaryCards: [],
|
||||
// 세액 산출 내역 테이블
|
||||
referenceTable: {
|
||||
title: '2026년 1사분기 세액 산출 내역',
|
||||
columns: [
|
||||
{ key: 'category', label: '구분', align: 'center' },
|
||||
{ key: 'amount', label: '금액', align: 'right' },
|
||||
{ key: 'note', label: '비고', align: 'left' },
|
||||
],
|
||||
data: [
|
||||
{ category: '매출세액', amount: '11,000,000', note: '과세매출 X 10%' },
|
||||
{ category: '매입세액', amount: '1,000,000', note: '공제대상 매입 X 10%' },
|
||||
{ category: '경감·공제세액', amount: '0', note: '해당없음' },
|
||||
],
|
||||
},
|
||||
// 예상 납부세액 계산
|
||||
calculationCards: {
|
||||
title: '예상 납부세액 계산',
|
||||
cards: [
|
||||
{ label: '매출세액', value: 11000000, unit: '원' },
|
||||
{ label: '매입세액', value: 1000000, unit: '원', operator: '-' },
|
||||
{ label: '경감·공제세액', value: 0, unit: '원', operator: '-' },
|
||||
{ label: '예상 납부세액', value: 10000000, unit: '원', operator: '=' },
|
||||
],
|
||||
},
|
||||
// 세금계산서 미발행/미수취 내역
|
||||
table: {
|
||||
title: '세금계산서 미발행/미수취 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'type', label: '구분', align: 'center' },
|
||||
{ key: 'issueDate', label: '발행일자', align: 'center', format: 'date' },
|
||||
{ key: 'vendor', label: '거래처', align: 'left' },
|
||||
{ key: 'vat', label: '부가세', align: 'right', format: 'currency' },
|
||||
{ key: 'invoiceStatus', label: '세금계산서 발행', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처1', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처2', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처3', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처4', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처5', vat: 11000000, invoiceStatus: '미발행' },
|
||||
{ type: '매입', issueDate: '2025-12-12', vendor: '거래처6', vat: 11000000, invoiceStatus: '미수취' },
|
||||
{ type: '매출', issueDate: '2025-12-12', vendor: '거래처7', vat: 11000000, invoiceStatus: '미발행' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'type',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '매출', label: '매출' },
|
||||
{ value: '매입', label: '매입' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'invoiceStatus',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '미발행', label: '미발행' },
|
||||
{ value: '미수취', label: '미수취' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 111000000,
|
||||
totalColumnKey: 'vat',
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { DetailModalConfig } from '../types';
|
||||
|
||||
/**
|
||||
* 복리후생비 현황 모달 설정
|
||||
* 모든 카드가 동일한 상세 모달
|
||||
* @param calculationType - 계산 방식 ('fixed': 직원당 정액 금액/월, 'percentage': 연봉 총액 비율)
|
||||
*/
|
||||
export function getWelfareModalConfig(calculationType: 'fixed' | 'percentage'): DetailModalConfig {
|
||||
// 계산 방식에 따른 조건부 calculationCards 생성
|
||||
const calculationCards = calculationType === 'fixed'
|
||||
? {
|
||||
// 직원당 정액 금액/월 방식
|
||||
title: '복리후생비 계산',
|
||||
subtitle: '직원당 정액 금액/월 200,000원',
|
||||
cards: [
|
||||
{ label: '직원 수', value: 20, unit: '명' },
|
||||
{ label: '연간 직원당 월급 금액', value: 2400000, unit: '원', operator: '×' as const },
|
||||
{ label: '당해년도 복리후생비 총 한도', value: 48000000, unit: '원', operator: '=' as const },
|
||||
],
|
||||
}
|
||||
: {
|
||||
// 연봉 총액 비율 방식
|
||||
title: '복리후생비 계산',
|
||||
subtitle: '연봉 총액 기준 비율 20.5%',
|
||||
cards: [
|
||||
{ label: '연봉 총액', value: 1000000000, unit: '원' },
|
||||
{ label: '비율', value: 20.5, unit: '%', operator: '×' as const },
|
||||
{ label: '당해년도 복리후생비 총 한도', value: 205000000, unit: '원', operator: '=' as const },
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
title: '복리후생비 상세',
|
||||
summaryCards: [
|
||||
// 1행: 당해년도 기준
|
||||
{ label: '당해년도 복리후생비 계정', value: 3123000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 한도', value: 600000, unit: '원' },
|
||||
{ label: '당해년도 복리후생비 사용', value: 6000000, unit: '원' },
|
||||
{ label: '당해년도 잔여한도', value: 0, unit: '원' },
|
||||
// 2행: 1사분기 기준
|
||||
{ label: '1사분기 복리후생비 총 한도', value: 3123000, unit: '원' },
|
||||
{ label: '1사분기 복리후생비 잔여한도', value: 6000000, unit: '원' },
|
||||
{ label: '1사분기 복리후생비 사용금액', value: 6000000, unit: '원' },
|
||||
{ label: '1사분기 복리후생비 초과 금액', value: 6000000, unit: '원' },
|
||||
],
|
||||
barChart: {
|
||||
title: '월별 복리후생비 사용 추이',
|
||||
data: [
|
||||
{ name: '1월', value: 1500000 },
|
||||
{ name: '2월', value: 1800000 },
|
||||
{ name: '3월', value: 2200000 },
|
||||
{ name: '4월', value: 1900000 },
|
||||
{ name: '5월', value: 2100000 },
|
||||
{ name: '6월', value: 1700000 },
|
||||
],
|
||||
dataKey: 'value',
|
||||
xAxisKey: 'name',
|
||||
color: '#60A5FA',
|
||||
},
|
||||
pieChart: {
|
||||
title: '항목별 사용 비율',
|
||||
data: [
|
||||
{ name: '식비', value: 55000000, percentage: 55, color: '#FBBF24' },
|
||||
{ name: '건강검진', value: 25000000, percentage: 5, color: '#60A5FA' },
|
||||
{ name: '경조사비', value: 10000000, percentage: 10, color: '#F87171' },
|
||||
{ name: '기타', value: 10000000, percentage: 30, color: '#34D399' },
|
||||
],
|
||||
},
|
||||
table: {
|
||||
title: '일별 복리후생비 사용 내역',
|
||||
columns: [
|
||||
{ key: 'no', label: 'No.', align: 'center' },
|
||||
{ key: 'cardName', label: '카드명', align: 'left' },
|
||||
{ key: 'user', label: '사용자', align: 'center' },
|
||||
{ key: 'date', label: '사용일자', align: 'center', format: 'date' },
|
||||
{ key: 'store', label: '가맹점명', align: 'left' },
|
||||
{ key: 'amount', label: '사용금액', align: 'right', format: 'currency' },
|
||||
{ key: 'usageType', label: '사용항목', align: 'center' },
|
||||
],
|
||||
data: [
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1000000, usageType: '식비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1200000, usageType: '건강검진' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1500000, usageType: '경조사비' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 1300000, usageType: '기타' },
|
||||
{ cardName: '카드명', user: '홍길동', date: '2025-12-12 12:12', store: '가맹점명', amount: 6000000, usageType: '식비' },
|
||||
],
|
||||
filters: [
|
||||
{
|
||||
key: 'usageType',
|
||||
options: [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '식비', label: '식비' },
|
||||
{ value: '건강검진', label: '건강검진' },
|
||||
{ value: '경조사비', label: '경조사비' },
|
||||
{ value: '기타', label: '기타' },
|
||||
],
|
||||
defaultValue: 'all',
|
||||
},
|
||||
{
|
||||
key: 'sortOrder',
|
||||
options: [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '금액 높은순' },
|
||||
{ value: 'amountAsc', label: '금액 낮은순' },
|
||||
],
|
||||
defaultValue: 'latest',
|
||||
},
|
||||
],
|
||||
showTotal: true,
|
||||
totalLabel: '합계',
|
||||
totalValue: 11000000,
|
||||
totalColumnKey: 'amount',
|
||||
},
|
||||
// 복리후생비 계산 (조건부 - calculationType에 따라)
|
||||
calculationCards,
|
||||
// 복리후생비 현황 (분기별 테이블)
|
||||
quarterlyTable: {
|
||||
title: '복리후생비 현황',
|
||||
rows: [
|
||||
{ label: '한도금액', q1: 12000000, q2: 12000000, q3: 12000000, q4: 12000000, total: 48000000 },
|
||||
{ label: '이월금액', q1: 0, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '사용금액', q1: 1000000, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '잔여한도', q1: 11000000, q2: '', q3: '', q4: '', total: '' },
|
||||
{ label: '초과금액', q1: '', q2: '', q3: '', q4: '', total: '' },
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -56,7 +56,7 @@ const formatCurrency = (value: number): string => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 요약 카드 컴포넌트
|
||||
* 요약 카드 컴포넌트 - 모바일 반응형 지원
|
||||
*/
|
||||
const SummaryCard = ({ data }: { data: SummaryCardData }) => {
|
||||
const displayValue = typeof data.value === 'number'
|
||||
@@ -64,10 +64,10 @@ const SummaryCard = ({ data }: { data: SummaryCardData }) => {
|
||||
: data.value;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">{data.label}</p>
|
||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-1">{data.label}</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
"text-lg sm:text-2xl font-bold break-all",
|
||||
data.isComparison && (data.isPositive ? "text-blue-600" : "text-red-600")
|
||||
)}>
|
||||
{data.isComparison && !data.isPositive && typeof data.value === 'string' && !data.value.startsWith('-') ? '-' : ''}
|
||||
@@ -78,27 +78,29 @@ const SummaryCard = ({ data }: { data: SummaryCardData }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 막대 차트 컴포넌트
|
||||
* 막대 차트 컴포넌트 - 모바일 반응형 지원
|
||||
*/
|
||||
const BarChartSection = ({ config }: { config: BarChartConfig }) => {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
|
||||
<div className="h-[150px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={config.data} margin={{ top: 5, right: 5, left: -20, bottom: 5 }}>
|
||||
<BarChart data={config.data} margin={{ top: 5, right: 5, left: -25, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E5E7EB" />
|
||||
<XAxis
|
||||
dataKey={config.xAxisKey}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#6B7280' }}
|
||||
tick={{ fontSize: 10, fill: '#6B7280' }}
|
||||
interval={0}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 10, fill: '#6B7280' }}
|
||||
tick={{ fontSize: 9, fill: '#6B7280' }}
|
||||
tickFormatter={(value) => value >= 10000 ? `${value / 10000}만` : value}
|
||||
width={35}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [formatCurrency(value) + '원', '']}
|
||||
@@ -108,7 +110,7 @@ const BarChartSection = ({ config }: { config: BarChartConfig }) => {
|
||||
dataKey={config.dataKey}
|
||||
fill={config.color || '#60A5FA'}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={40}
|
||||
maxBarSize={30}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
@@ -118,21 +120,21 @@ const BarChartSection = ({ config }: { config: BarChartConfig }) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* 도넛 차트 컴포넌트
|
||||
* 도넛 차트 컴포넌트 - 모바일 반응형 지원
|
||||
*/
|
||||
const PieChartSection = ({ config }: { config: PieChartConfig }) => {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-lg p-4">
|
||||
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 overflow-hidden">
|
||||
<p className="text-sm font-medium text-gray-700 mb-4">{config.title}</p>
|
||||
{/* 도넛 차트 - 중앙 정렬 */}
|
||||
{/* 도넛 차트 - 중앙 정렬, 모바일 크기 조절 */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<PieChart width={120} height={120}>
|
||||
<PieChart width={100} height={100}>
|
||||
<Pie
|
||||
data={config.data}
|
||||
cx={60}
|
||||
cy={60}
|
||||
innerRadius={35}
|
||||
outerRadius={55}
|
||||
cx={50}
|
||||
cy={50}
|
||||
innerRadius={28}
|
||||
outerRadius={45}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
>
|
||||
@@ -142,19 +144,19 @@ const PieChartSection = ({ config }: { config: PieChartConfig }) => {
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</div>
|
||||
{/* 범례 - 차트 아래 배치 */}
|
||||
{/* 범례 - 세로 배치 (모바일 최적화) */}
|
||||
<div className="space-y-2">
|
||||
{config.data.map((item, index) => (
|
||||
<div key={index} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div key={index} className="flex items-center justify-between text-xs sm:text-sm gap-2">
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 min-w-0 flex-shrink">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||
className="w-2.5 h-2.5 sm:w-3 sm:h-3 rounded-full flex-shrink-0"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-gray-600">{item.name}</span>
|
||||
<span className="text-gray-400">{item.percentage}%</span>
|
||||
<span className="text-gray-600 truncate">{item.name}</span>
|
||||
<span className="text-gray-400 flex-shrink-0">{item.percentage}%</span>
|
||||
</div>
|
||||
<span className="font-medium text-gray-900">
|
||||
<span className="font-medium text-gray-900 flex-shrink-0">
|
||||
{formatCurrency(item.value)}원
|
||||
</span>
|
||||
</div>
|
||||
@@ -355,7 +357,7 @@ const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig })
|
||||
};
|
||||
|
||||
/**
|
||||
* 분기별 테이블 섹션 컴포넌트 (접대비 현황 등)
|
||||
* 분기별 테이블 섹션 컴포넌트 (접대비 현황 등) - 가로 스크롤 지원
|
||||
*/
|
||||
const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => {
|
||||
const formatValue = (value: number | string | undefined): string => {
|
||||
@@ -367,8 +369,8 @@ const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) =>
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<div className="border rounded-lg overflow-auto">
|
||||
<table className="w-full min-w-[500px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="px-4 py-3 text-xs font-medium text-gray-600 text-left">구분</th>
|
||||
@@ -401,7 +403,7 @@ const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) =>
|
||||
};
|
||||
|
||||
/**
|
||||
* 참조 테이블 컴포넌트 (필터 없는 정보성 테이블)
|
||||
* 참조 테이블 컴포넌트 (필터 없는 정보성 테이블) - 가로 스크롤 지원
|
||||
*/
|
||||
const ReferenceTableSection = ({ config }: { config: ReferenceTableConfig }) => {
|
||||
const getAlignClass = (align?: string): string => {
|
||||
@@ -418,8 +420,8 @@ const ReferenceTableSection = ({ config }: { config: ReferenceTableConfig }) =>
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<h4 className="font-medium text-gray-800 mb-3">{config.title}</h4>
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full">
|
||||
<div className="border rounded-lg overflow-auto">
|
||||
<table className="w-full min-w-[400px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
{config.columns.map((column) => (
|
||||
@@ -577,9 +579,9 @@ const TableSection = ({ config }: { config: TableConfig }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="border rounded-lg max-h-[400px] overflow-y-auto">
|
||||
<table className="w-full">
|
||||
{/* 테이블 - 가로 스크롤 지원 */}
|
||||
<div className="border rounded-lg max-h-[400px] overflow-auto">
|
||||
<table className="w-full min-w-[600px]">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-gray-100">
|
||||
{config.columns.map((column) => (
|
||||
@@ -683,12 +685,12 @@ const TableSection = ({ config }: { config: TableConfig }) => {
|
||||
*/
|
||||
export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="!w-[85vw] !max-w-[1600px] max-h-[90vh] overflow-y-auto p-0">
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()} >
|
||||
<DialogContent className="!w-[95vw] sm:!w-[90vw] md:!w-[85vw] !max-w-[1600px] max-h-[90vh] overflow-auto p-0">
|
||||
{/* 헤더 */}
|
||||
<DialogHeader className="sticky top-0 bg-white z-10 px-6 py-4 border-b">
|
||||
<DialogHeader className="sticky top-0 bg-white z-10 px-4 sm:px-6 py-3 sm:py-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle className="text-lg font-bold">{config.title}</DialogTitle>
|
||||
<DialogTitle className="text-base sm:text-lg font-bold">{config.title}</DialogTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -699,14 +701,14 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) {
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-6 space-y-6">
|
||||
{/* 요약 카드 영역 */}
|
||||
<div className="p-4 sm:p-6 space-y-4 sm:space-y-6">
|
||||
{/* 요약 카드 영역 - 모바일: 세로배치 */}
|
||||
{config.summaryCards.length > 0 && (
|
||||
<div className={cn(
|
||||
"grid gap-4",
|
||||
config.summaryCards.length === 2 && "grid-cols-2",
|
||||
config.summaryCards.length === 3 && "grid-cols-3",
|
||||
config.summaryCards.length >= 4 && "grid-cols-2 md:grid-cols-4"
|
||||
"grid gap-3 sm:gap-4",
|
||||
config.summaryCards.length === 2 && "grid-cols-1 sm:grid-cols-2",
|
||||
config.summaryCards.length === 3 && "grid-cols-1 sm:grid-cols-3",
|
||||
config.summaryCards.length >= 4 && "grid-cols-1 sm:grid-cols-2 md:grid-cols-4"
|
||||
)}>
|
||||
{config.summaryCards.map((card, index) => (
|
||||
<SummaryCard key={index} data={card} />
|
||||
|
||||
@@ -32,7 +32,7 @@ export function CardManagementSection({ data, onCardClick }: CardManagementSecti
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
|
||||
@@ -18,7 +18,7 @@ export function DailyReportSection({ data, onClick }: DailyReportSectionProps) {
|
||||
<span className="text-sm text-muted-foreground">{data.date}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem key={card.id} card={card} onClick={onClick} />
|
||||
))}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function EntertainmentSection({ data, onCardClick }: EntertainmentSection
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="접대비 현황" badge="warning" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
|
||||
@@ -15,7 +15,7 @@ export function MonthlyExpenseSection({ data, onCardClick }: MonthlyExpenseSecti
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="당월 예상 지출 내역" badge="warning" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
|
||||
@@ -34,7 +34,7 @@ export function ReceivableSection({ data }: ReceivableSectionProps) {
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
|
||||
@@ -39,14 +39,14 @@ export function TodayIssueSection({ items, itemSettings }: TodayIssueSectionProp
|
||||
})
|
||||
: items;
|
||||
|
||||
// 아이템 개수에 따른 동적 그리드 클래스
|
||||
// 아이템 개수에 따른 동적 그리드 클래스 (xs: 344px Galaxy Fold 지원)
|
||||
const getGridColsClass = () => {
|
||||
const count = filteredItems.length;
|
||||
if (count <= 1) return 'grid-cols-1';
|
||||
if (count === 2) return 'grid-cols-2';
|
||||
if (count === 3) return 'grid-cols-3';
|
||||
if (count === 2) return 'grid-cols-1 xs:grid-cols-2';
|
||||
if (count === 3) return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-3';
|
||||
// 4개 이상: 최대 4열, 넘치면 아래로
|
||||
return 'grid-cols-2 md:grid-cols-4';
|
||||
return 'grid-cols-1 xs:grid-cols-2 md:grid-cols-4';
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ export function WelfareSection({ data, onCardClick }: WelfareSectionProps) {
|
||||
<CardContent className="p-6">
|
||||
<SectionTitle title="복리후생비 현황" badge="info" />
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||
<div className="grid grid-cols-1 xs:grid-cols-2 md:grid-cols-4 gap-3 xs:gap-4 mb-4">
|
||||
{data.cards.map((card) => (
|
||||
<AmountCardItem
|
||||
key={card.id}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { FolderTree, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { Category } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import {
|
||||
getCategories,
|
||||
createCategory,
|
||||
@@ -60,6 +62,7 @@ export function CategoryManagement() {
|
||||
toast.error(result.error || '카테고리 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('카테고리 목록 조회 실패:', error);
|
||||
toast.error('카테고리 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
@@ -87,6 +90,7 @@ export function CategoryManagement() {
|
||||
toast.error(result.error || '카테고리 추가에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('카테고리 추가 실패:', error);
|
||||
toast.error('카테고리 추가에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -122,6 +126,7 @@ export function CategoryManagement() {
|
||||
toast.error(result.error || '카테고리 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('카테고리 삭제 실패:', error);
|
||||
toast.error('카테고리 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -146,6 +151,7 @@ export function CategoryManagement() {
|
||||
toast.error(result.error || '카테고리 수정에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('카테고리 수정 실패:', error);
|
||||
toast.error('카테고리 수정에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -181,6 +187,7 @@ export function CategoryManagement() {
|
||||
loadCategories();
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('순서 변경 실패:', error);
|
||||
toast.error('순서 변경에 실패했습니다.');
|
||||
// 실패시 원래 순서로 복구
|
||||
@@ -255,10 +262,7 @@ export function CategoryManagement() {
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
<ContentLoadingSpinner text="카테고리 목록을 불러오는 중..." />
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{categories.map((category, index) => (
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
type ElectronicApproval,
|
||||
getEmptyElectronicApproval,
|
||||
} from '../common';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
@@ -150,6 +151,7 @@ export default function ContractDetailForm({
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -174,6 +176,7 @@ export default function ContractDetailForm({
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -256,6 +259,7 @@ export default function ContractDetailForm({
|
||||
try {
|
||||
await downloadFileById(parseInt(fileId), fileName);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ContractDetailForm] 다운로드 실패:', error);
|
||||
toast.error('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
|
||||
@@ -36,8 +36,9 @@ import {
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Pagination } from './Pagination';
|
||||
import { TabFilter } from './TabFilter';
|
||||
import { SearchFilter } from './SearchFilter';
|
||||
@@ -340,10 +341,7 @@ export function DataTable<T extends BaseDataItem>({
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
<span className="ml-3 text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
<ContentLoadingSpinner text="데이터를 불러오는 중..." />
|
||||
) : paginatedData.length === 0 ? (
|
||||
renderEmptyState()
|
||||
) : (
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
Plus,
|
||||
FileText,
|
||||
Edit,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
SORT_OPTIONS,
|
||||
FILTER_OPTIONS,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
export function AttendanceManagement() {
|
||||
const router = useRouter();
|
||||
@@ -108,6 +109,7 @@ export function AttendanceManagement() {
|
||||
setAttendanceRecords(attendancesResult.data);
|
||||
setTotal(attendancesResult.total);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[AttendanceManagement] fetchData error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -340,6 +342,7 @@ export function AttendanceManagement() {
|
||||
}
|
||||
setAttendanceDialogOpen(false);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Save attendance error:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -552,11 +555,7 @@ export function AttendanceManagement() {
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="근태 정보를 불러오는 중..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
deleteDepartmentsMany,
|
||||
type DepartmentRecord,
|
||||
} from './actions';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
/**
|
||||
* API 응답을 로컬 Department 타입으로 변환
|
||||
@@ -77,6 +78,7 @@ export function DepartmentManagement() {
|
||||
console.error('[DepartmentManagement] fetchDepartments error:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DepartmentManagement] fetchDepartments error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -221,6 +223,7 @@ export function DepartmentManagement() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DepartmentManagement] confirmDelete error:', error);
|
||||
} finally {
|
||||
setDeleteDialogOpen(false);
|
||||
@@ -260,6 +263,7 @@ export function DepartmentManagement() {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DepartmentManagement] handleDialogSubmit error:', error);
|
||||
} finally {
|
||||
setDialogOpen(false);
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
DEFAULT_FIELD_SETTINGS,
|
||||
USER_ROLE_LABELS,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// 필터 옵션 타입
|
||||
type FilterOption = 'all' | 'hasUserId' | 'noUserId' | 'active' | 'leave' | 'resigned';
|
||||
@@ -116,6 +117,7 @@ export function EmployeeManagement() {
|
||||
setEmployees(result.data);
|
||||
setTotal(result.total);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[EmployeeManagement] fetchEmployees error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -312,6 +314,7 @@ export function EmployeeManagement() {
|
||||
console.error('Bulk delete failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Bulk delete error:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
@@ -341,6 +344,7 @@ export function EmployeeManagement() {
|
||||
console.error('Delete failed:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Delete error:', error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
SORT_OPTIONS,
|
||||
formatCurrency,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
export function SalaryManagement() {
|
||||
// ===== 상태 관리 =====
|
||||
@@ -98,6 +99,7 @@ export function SalaryManagement() {
|
||||
toast.error(result.error || '급여 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('loadSalaries error:', error);
|
||||
toast.error('급여 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
@@ -147,6 +149,7 @@ export function SalaryManagement() {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleMarkCompleted error:', error);
|
||||
toast.error('상태 변경에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -173,6 +176,7 @@ export function SalaryManagement() {
|
||||
toast.error(result.error || '상태 변경에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleMarkScheduled error:', error);
|
||||
toast.error('상태 변경에 실패했습니다.');
|
||||
} finally {
|
||||
@@ -193,6 +197,7 @@ export function SalaryManagement() {
|
||||
toast.error(result.error || '급여 상세 정보를 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleViewDetail error:', error);
|
||||
toast.error('급여 상세 정보를 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
@@ -215,6 +220,7 @@ export function SalaryManagement() {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('handleSaveDetail error:', error);
|
||||
toast.error('저장에 실패했습니다.');
|
||||
} finally {
|
||||
|
||||
@@ -74,6 +74,7 @@ import {
|
||||
REQUEST_STATUS_LABELS,
|
||||
REQUEST_STATUS_COLORS,
|
||||
} from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== Mock 데이터 생성 (request 탭용 - 신청 현황은 leaves API 사용 예정) =====
|
||||
|
||||
@@ -170,6 +171,7 @@ export function VacationManagement() {
|
||||
setUsageData(converted);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VacationManagement] fetchUsageData error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -204,6 +206,7 @@ export function VacationManagement() {
|
||||
setGrantData(converted);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VacationManagement] fetchGrantData error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -246,6 +249,7 @@ export function VacationManagement() {
|
||||
setRequestData(converted);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VacationManagement] fetchLeaveRequests error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -344,6 +348,7 @@ export function VacationManagement() {
|
||||
console.error('[VacationManagement] 승인 실패:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VacationManagement] handleApproveConfirm error:', error);
|
||||
} finally {
|
||||
setSelectedItems(new Set());
|
||||
@@ -369,6 +374,7 @@ export function VacationManagement() {
|
||||
console.error('[VacationManagement] 반려 실패:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VacationManagement] handleRejectConfirm error:', error);
|
||||
} finally {
|
||||
setSelectedItems(new Set());
|
||||
@@ -750,6 +756,7 @@ export function VacationManagement() {
|
||||
console.error('[VacationManagement] 휴가 부여 실패:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VacationManagement] 휴가 부여 에러:', error);
|
||||
alert('휴가 부여 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
@@ -779,6 +786,7 @@ export function VacationManagement() {
|
||||
console.error('[VacationManagement] 휴가 신청 실패:', result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[VacationManagement] 휴가 신청 에러:', error);
|
||||
alert('휴가 신청 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
|
||||
@@ -31,6 +31,7 @@ import type { ItemType, BendingDetail } from '@/types/item';
|
||||
import type { ItemFieldResponse } from '@/types/item-master-api';
|
||||
import { uploadItemFile, ItemFileType, checkItemCodeDuplicate, DuplicateCheckResult } from '@/lib/api/items';
|
||||
import { DuplicateCodeError } from '@/lib/api/error-handler';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
/**
|
||||
* 메인 DynamicItemForm 컴포넌트
|
||||
@@ -356,6 +357,7 @@ export default function DynamicItemForm({
|
||||
});
|
||||
console.log('[DynamicItemForm] 전개도 파일 업로드 성공');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DynamicItemForm] 전개도 파일 업로드 실패:', error);
|
||||
fileUploadErrors.push('전개도 이미지');
|
||||
}
|
||||
@@ -372,6 +374,7 @@ export default function DynamicItemForm({
|
||||
});
|
||||
console.log('[DynamicItemForm] 시방서 파일 업로드 성공');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DynamicItemForm] 시방서 파일 업로드 실패:', error);
|
||||
fileUploadErrors.push('시방서');
|
||||
}
|
||||
@@ -402,6 +405,7 @@ export default function DynamicItemForm({
|
||||
});
|
||||
console.log('[DynamicItemForm] 인정서 파일 업로드 성공');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DynamicItemForm] 인정서 파일 업로드 실패:', error);
|
||||
fileUploadErrors.push('인정서');
|
||||
}
|
||||
@@ -1025,6 +1029,7 @@ export default function DynamicItemForm({
|
||||
setBendingDiagramFile(file);
|
||||
console.log('[DynamicItemForm] 드로잉 캔버스 → File 변환 성공:', file.name);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[DynamicItemForm] 드로잉 캔버스 → File 변환 실패:', error);
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from '@/components/ui/command';
|
||||
import { Check, Package, Plus, Search, Trash2, Loader2 } from 'lucide-react';
|
||||
import type { BOMLine, BOMSearchState, DynamicSection } from '../types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
/**
|
||||
* 품목 유형(FG, PT, SM, RM, CS)을 BOM child_item_type으로 변환
|
||||
@@ -138,6 +139,7 @@ export default function DynamicBOMSection({
|
||||
setSearchResults((prev) => ({ ...prev, [lineId]: mappedItems }));
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('품목 검색 실패:', error);
|
||||
setSearchResults((prev) => ({ ...prev, [lineId]: [] }));
|
||||
} finally {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { ArrowLeft, Edit, Package, FileImage, Download, FileText, Check, Calendar } from 'lucide-react';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
item: ItemMaster;
|
||||
@@ -69,6 +70,7 @@ async function handleFileDownload(fileId: number | undefined, fileName?: string)
|
||||
try {
|
||||
await downloadFileById(fileId, fileName);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[ItemDetailClient] 다운로드 실패:', error);
|
||||
alert('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Search, Plus, Edit, Trash2, Package, Loader2 } from 'lucide-react';
|
||||
import { TableLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { useItemList } from '@/hooks/useItemList';
|
||||
import { handleApiError } from '@/lib/api/error-handler';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import {
|
||||
IntegratedListTemplateV2,
|
||||
type TabOption,
|
||||
@@ -198,6 +199,7 @@ export default function ItemListClient() {
|
||||
throw new Error(result.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('품목 삭제 실패:', error);
|
||||
alert(error instanceof Error ? error.message : '품목 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
|
||||
@@ -425,6 +425,29 @@ function ItemMasterDataManagementContent() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const setNewPageItemTypeWrapper: React.Dispatch<React.SetStateAction<'FG' | 'PT' | 'SM' | 'RM' | 'CS'>> = setNewPageItemType as any;
|
||||
|
||||
// SectionTemplateDialog 카테고리 래퍼 (string | string[] → string[])
|
||||
const handleSetNewSectionTemplateCategory = (category: string | string[]) => {
|
||||
if (Array.isArray(category)) {
|
||||
setNewSectionTemplateCategory(category);
|
||||
} else {
|
||||
setNewSectionTemplateCategory(category ? [category] : []);
|
||||
}
|
||||
};
|
||||
|
||||
// LoadTemplateDialog 선택 ID 래퍼 (string | number | null → string | null)
|
||||
const handleSetSelectedTemplateId = (id: string | number | null) => {
|
||||
if (id === null) {
|
||||
setSelectedTemplateId(null);
|
||||
} else {
|
||||
setSelectedTemplateId(String(id));
|
||||
}
|
||||
};
|
||||
|
||||
// TemplateFieldDialog 선택 ID 래퍼 (string | number | null → string)
|
||||
const handleSetTemplateFieldSelectedMasterFieldId = (id: string | number | null) => {
|
||||
setTemplateFieldSelectedMasterFieldId(id === null ? '' : String(id));
|
||||
};
|
||||
|
||||
// 초기 로딩 중 UI
|
||||
if (isInitialLoading) {
|
||||
return (
|
||||
@@ -782,7 +805,7 @@ function ItemMasterDataManagementContent() {
|
||||
newFieldDescription={newFieldDescription}
|
||||
setNewFieldDescription={setNewFieldDescription}
|
||||
newFieldOptions={newFieldOptions}
|
||||
setNewFieldOptions={setNewFieldOptions}
|
||||
setNewFieldOptions={setNewFieldOptions as (options: string | string[]) => void}
|
||||
selectedSectionForField={selectedPage?.sections.find(s => s.id === selectedSectionForField) || null}
|
||||
selectedPage={selectedPage || null}
|
||||
itemMasterFields={itemMasterFields}
|
||||
@@ -830,7 +853,7 @@ function ItemMasterDataManagementContent() {
|
||||
newFieldDescription={newFieldDescription}
|
||||
setNewFieldDescription={setNewFieldDescription}
|
||||
newFieldOptions={newFieldOptions}
|
||||
setNewFieldOptions={setNewFieldOptions}
|
||||
setNewFieldOptions={setNewFieldOptions as (options: string | string[]) => void}
|
||||
selectedSectionForField={selectedPage?.sections.find(s => s.id === selectedSectionForField) || null}
|
||||
selectedPage={selectedPage || null}
|
||||
itemMasterFields={itemMasterFields}
|
||||
@@ -899,7 +922,7 @@ function ItemMasterDataManagementContent() {
|
||||
newSectionTemplateDescription={newSectionTemplateDescription}
|
||||
setNewSectionTemplateDescription={setNewSectionTemplateDescription}
|
||||
newSectionTemplateCategory={newSectionTemplateCategory}
|
||||
setNewSectionTemplateCategory={setNewSectionTemplateCategory}
|
||||
setNewSectionTemplateCategory={handleSetNewSectionTemplateCategory}
|
||||
newSectionTemplateType={newSectionTemplateType}
|
||||
setNewSectionTemplateType={setNewSectionTemplateType}
|
||||
handleUpdateSectionTemplate={handleUpdateSectionTemplate}
|
||||
@@ -938,7 +961,7 @@ function ItemMasterDataManagementContent() {
|
||||
showMasterFieldList={templateFieldShowMasterFieldList}
|
||||
setShowMasterFieldList={setTemplateFieldShowMasterFieldList}
|
||||
selectedMasterFieldId={templateFieldSelectedMasterFieldId}
|
||||
setSelectedMasterFieldId={setTemplateFieldSelectedMasterFieldId}
|
||||
setSelectedMasterFieldId={handleSetTemplateFieldSelectedMasterFieldId}
|
||||
/>
|
||||
|
||||
<LoadTemplateDialog
|
||||
@@ -946,7 +969,7 @@ function ItemMasterDataManagementContent() {
|
||||
setIsLoadTemplateDialogOpen={setIsLoadTemplateDialogOpen}
|
||||
sectionTemplates={sectionTemplates}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
setSelectedTemplateId={setSelectedTemplateId}
|
||||
setSelectedTemplateId={handleSetSelectedTemplateId}
|
||||
handleLoadTemplate={handleLoadTemplateWrapper}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import type { ItemPage, SectionTemplate, ItemMasterField, ItemSection, BOMItem } from '@/contexts/ItemMasterContext';
|
||||
import { FieldDialog } from '../dialogs/FieldDialog';
|
||||
import { FieldDialog, type InputType } from '../dialogs/FieldDialog';
|
||||
import { FieldDrawer } from '../dialogs/FieldDrawer';
|
||||
import { TabManagementDialogs } from '../dialogs/TabManagementDialogs';
|
||||
import { OptionDialog } from '../dialogs/OptionDialog';
|
||||
@@ -17,30 +17,18 @@ import { SectionTemplateDialog } from '../dialogs/SectionTemplateDialog';
|
||||
import { ImportSectionDialog } from '../dialogs/ImportSectionDialog';
|
||||
import { ImportFieldDialog } from '../dialogs/ImportFieldDialog';
|
||||
import type { CustomTab, AttributeSubTab } from '../hooks/useTabManagement';
|
||||
import type { UnitOption } from '../hooks/useAttributeManagement';
|
||||
import type { OptionColumn } from '../types';
|
||||
import type { ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
|
||||
import type { SectionUsageResponse, FieldUsageResponse } from '@/types/item-master-api';
|
||||
|
||||
// 텍스트박스 칼럼 타입 (FieldDialog와 동일)
|
||||
interface TextboxColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
}
|
||||
|
||||
interface ConditionField {
|
||||
fieldId: string;
|
||||
fieldName: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
logicOperator?: 'AND' | 'OR';
|
||||
}
|
||||
|
||||
interface ConditionSection {
|
||||
sectionId: string;
|
||||
sectionTitle: string;
|
||||
operator: string;
|
||||
value: string;
|
||||
logicOperator?: 'AND' | 'OR';
|
||||
}
|
||||
|
||||
// 속성 컬럼 타입 (OptionDialog/ColumnManageDialog 호환)
|
||||
interface AttributeColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -49,6 +37,26 @@ interface AttributeColumn {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
// 조건부 표시 필드 타입 - 유연한 타입 (FieldDialog.tsx의 FlexibleConditionField와 호환)
|
||||
interface ConditionField {
|
||||
fieldId?: string;
|
||||
fieldKey?: string;
|
||||
fieldName?: string;
|
||||
operator?: string;
|
||||
value?: string;
|
||||
expectedValue?: string;
|
||||
logicOperator?: 'AND' | 'OR';
|
||||
}
|
||||
|
||||
// 조건부 표시 섹션 타입 - 유연한 타입 (FieldDialog.tsx와 호환)
|
||||
interface ConditionSection {
|
||||
sectionId?: string;
|
||||
sectionTitle?: string;
|
||||
operator?: string;
|
||||
value?: string;
|
||||
logicOperator?: 'AND' | 'OR';
|
||||
}
|
||||
|
||||
export interface ItemMasterDialogsProps {
|
||||
isMobile: boolean;
|
||||
selectedPage: ItemPage | null;
|
||||
@@ -108,8 +116,9 @@ export interface ItemMasterDialogsProps {
|
||||
setNewOptionInputType: (type: string) => void;
|
||||
newOptionRequired: boolean;
|
||||
setNewOptionRequired: (required: boolean) => void;
|
||||
newOptionOptions: string[];
|
||||
setNewOptionOptions: (options: string[]) => void;
|
||||
// string | string[] 모두 지원
|
||||
newOptionOptions: string | string[];
|
||||
setNewOptionOptions: (options: string | string[]) => void;
|
||||
newOptionPlaceholder: string;
|
||||
setNewOptionPlaceholder: (placeholder: string) => void;
|
||||
newOptionDefaultValue: string;
|
||||
@@ -158,12 +167,13 @@ export interface ItemMasterDialogsProps {
|
||||
newSectionDescription: string;
|
||||
setNewSectionDescription: (description: string) => void;
|
||||
handleAddSection: () => void;
|
||||
sectionInputMode: 'new' | 'existing';
|
||||
setSectionInputMode: (mode: 'new' | 'existing') => void;
|
||||
// 'new'/'existing' 또는 'custom'/'template' 모두 지원
|
||||
sectionInputMode: 'new' | 'existing' | 'custom' | 'template';
|
||||
setSectionInputMode: (mode: 'new' | 'existing' | 'custom' | 'template') => void;
|
||||
sectionsAsTemplates: SectionTemplate[];
|
||||
selectedSectionTemplateId: number | null;
|
||||
setSelectedSectionTemplateId: (id: number | null) => void;
|
||||
handleLinkTemplate: () => void;
|
||||
handleLinkTemplate: ((template: SectionTemplate) => void | Promise<void>) | (() => void | Promise<void>);
|
||||
|
||||
// Field Dialog
|
||||
isFieldDialogOpen: boolean;
|
||||
@@ -171,22 +181,26 @@ export interface ItemMasterDialogsProps {
|
||||
selectedSectionForField: number | null;
|
||||
editingFieldId: number | null;
|
||||
setEditingFieldId: (id: number | null) => void;
|
||||
fieldInputMode: 'new' | 'existing';
|
||||
setFieldInputMode: (mode: 'new' | 'existing') => void;
|
||||
// 'new'/'existing' 또는 'custom'/'master' 모두 지원
|
||||
fieldInputMode: 'new' | 'existing' | 'custom' | 'master';
|
||||
setFieldInputMode: (mode: 'new' | 'existing' | 'custom' | 'master') => void;
|
||||
showMasterFieldList: boolean;
|
||||
setShowMasterFieldList: (show: boolean) => void;
|
||||
selectedMasterFieldId: number | null;
|
||||
setSelectedMasterFieldId: (id: number | null) => void;
|
||||
// string 또는 number | null 모두 지원
|
||||
selectedMasterFieldId: string | number | null;
|
||||
setSelectedMasterFieldId: (id: string | number | null) => void;
|
||||
textboxColumns: TextboxColumn[];
|
||||
setTextboxColumns: React.Dispatch<React.SetStateAction<TextboxColumn[]>>;
|
||||
newFieldConditionEnabled: boolean;
|
||||
setNewFieldConditionEnabled: (enabled: boolean) => void;
|
||||
newFieldConditionTargetType: 'field' | 'section';
|
||||
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
|
||||
newFieldConditionFields: ConditionField[];
|
||||
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<ConditionField[]>>;
|
||||
newFieldConditionSections: ConditionSection[];
|
||||
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<ConditionSection[]>>;
|
||||
// 유연한 조건부 필드 타입
|
||||
newFieldConditionFields: ConditionField[] | ConditionalFieldConfig[];
|
||||
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<ConditionField[]>> | React.Dispatch<React.SetStateAction<ConditionalFieldConfig[]>>;
|
||||
// 유연한 조건부 섹션 타입
|
||||
newFieldConditionSections: ConditionSection[] | string[];
|
||||
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<ConditionSection[]>> | React.Dispatch<React.SetStateAction<string[]>>;
|
||||
tempConditionValue: string;
|
||||
setTempConditionValue: (value: string) => void;
|
||||
newFieldName: string;
|
||||
@@ -199,8 +213,9 @@ export interface ItemMasterDialogsProps {
|
||||
setNewFieldRequired: (required: boolean) => void;
|
||||
newFieldDescription: string;
|
||||
setNewFieldDescription: (description: string) => void;
|
||||
newFieldOptions: string[];
|
||||
setNewFieldOptions: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
// string | string[] 모두 지원
|
||||
newFieldOptions: string | string[];
|
||||
setNewFieldOptions: ((options: string | string[]) => void) | React.Dispatch<React.SetStateAction<string[]>>;
|
||||
itemMasterFields: ItemMasterField[];
|
||||
handleAddField: () => void;
|
||||
isColumnDialogOpen: boolean;
|
||||
@@ -251,8 +266,9 @@ export interface ItemMasterDialogsProps {
|
||||
setNewSectionTemplateTitle: (title: string) => void;
|
||||
newSectionTemplateDescription: string;
|
||||
setNewSectionTemplateDescription: (description: string) => void;
|
||||
newSectionTemplateCategory: string;
|
||||
setNewSectionTemplateCategory: (category: string) => void;
|
||||
// string 또는 string[] 모두 지원
|
||||
newSectionTemplateCategory: string | string[];
|
||||
setNewSectionTemplateCategory: (category: string | string[]) => void;
|
||||
newSectionTemplateType: 'fields' | 'bom';
|
||||
setNewSectionTemplateType: (type: 'fields' | 'bom') => void;
|
||||
handleUpdateSectionTemplate: () => void;
|
||||
@@ -281,20 +297,24 @@ export interface ItemMasterDialogsProps {
|
||||
setTemplateFieldColumnCount: (count: number) => void;
|
||||
templateFieldColumnNames: string[];
|
||||
setTemplateFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
handleAddTemplateField: () => void;
|
||||
templateFieldInputMode: 'new' | 'existing';
|
||||
setTemplateFieldInputMode: (mode: 'new' | 'existing') => void;
|
||||
// () => void 또는 () => Promise<void> 모두 지원
|
||||
handleAddTemplateField: (() => void) | (() => Promise<void>);
|
||||
// 'new'/'existing' 또는 'custom'/'master' 모두 지원
|
||||
templateFieldInputMode: 'new' | 'existing' | 'custom' | 'master';
|
||||
setTemplateFieldInputMode: (mode: 'new' | 'existing' | 'custom' | 'master') => void;
|
||||
templateFieldShowMasterFieldList: boolean;
|
||||
setTemplateFieldShowMasterFieldList: (show: boolean) => void;
|
||||
templateFieldSelectedMasterFieldId: number | null;
|
||||
setTemplateFieldSelectedMasterFieldId: (id: number | null) => void;
|
||||
// string 또는 number | null 모두 지원
|
||||
templateFieldSelectedMasterFieldId: string | number | null;
|
||||
setTemplateFieldSelectedMasterFieldId: (id: string | number | null) => void;
|
||||
|
||||
// Load Template Dialog
|
||||
isLoadTemplateDialogOpen: boolean;
|
||||
setIsLoadTemplateDialogOpen: (open: boolean) => void;
|
||||
sectionTemplates: SectionTemplate[];
|
||||
selectedTemplateId: number | null;
|
||||
setSelectedTemplateId: (id: number | null) => void;
|
||||
// string | number | null 모두 지원
|
||||
selectedTemplateId: string | number | null;
|
||||
setSelectedTemplateId: (id: string | number | null) => void;
|
||||
handleLoadTemplate: () => void;
|
||||
|
||||
// Import Section Dialog
|
||||
@@ -303,18 +323,22 @@ export interface ItemMasterDialogsProps {
|
||||
independentSections: ItemSection[];
|
||||
selectedImportSectionId: number | null;
|
||||
setSelectedImportSectionId: (id: number | null) => void;
|
||||
handleImportSection: () => Promise<void>;
|
||||
// () => void 또는 () => Promise<void> 모두 지원
|
||||
handleImportSection: (() => void) | (() => Promise<void>);
|
||||
refreshIndependentSections: () => void;
|
||||
getSectionUsage: (sectionId: number) => Promise<{ pages: { id: number; name: string }[] }>;
|
||||
// 유연한 반환 타입 - SectionUsageResponse 또는 간단한 객체
|
||||
getSectionUsage: (sectionId: number) => Promise<SectionUsageResponse | { pages: { id: number; name: string }[] }>;
|
||||
|
||||
// Import Field Dialog
|
||||
isImportFieldDialogOpen: boolean;
|
||||
setIsImportFieldDialogOpen: (open: boolean) => void;
|
||||
selectedImportFieldId: number | null;
|
||||
setSelectedImportFieldId: (id: number | null) => void;
|
||||
handleImportField: () => Promise<void>;
|
||||
// () => void 또는 () => Promise<void> 모두 지원
|
||||
handleImportField: (() => void) | (() => Promise<void>);
|
||||
refreshIndependentFields: () => void;
|
||||
getFieldUsage: (fieldId: number) => Promise<{ sections: { id: number; title: string }[] }>;
|
||||
// 유연한 반환 타입 - FieldUsageResponse 또는 간단한 객체
|
||||
getFieldUsage: (fieldId: number) => Promise<FieldUsageResponse | { sections: { id: number; title: string }[] }>;
|
||||
importFieldTargetSectionId: number | null;
|
||||
}
|
||||
|
||||
@@ -918,8 +942,8 @@ export function ItemMasterDialogs({
|
||||
selectedSectionId={selectedImportSectionId}
|
||||
setSelectedSectionId={setSelectedImportSectionId}
|
||||
onImport={handleImportSection}
|
||||
onRefresh={refreshIndependentSections}
|
||||
onGetUsage={getSectionUsage}
|
||||
onRefresh={refreshIndependentSections as () => Promise<void>}
|
||||
onGetUsage={getSectionUsage as (sectionId: number) => Promise<SectionUsageResponse>}
|
||||
/>
|
||||
|
||||
{/* 필드 불러오기 다이얼로그 */}
|
||||
@@ -930,8 +954,8 @@ export function ItemMasterDialogs({
|
||||
selectedFieldId={selectedImportFieldId}
|
||||
setSelectedFieldId={setSelectedImportFieldId}
|
||||
onImport={handleImportField}
|
||||
onRefresh={refreshIndependentFields}
|
||||
onGetUsage={getFieldUsage}
|
||||
onRefresh={refreshIndependentFields as () => Promise<void>}
|
||||
onGetUsage={getFieldUsage as (fieldId: number) => Promise<FieldUsageResponse>}
|
||||
targetSectionTitle={
|
||||
importFieldTargetSectionId
|
||||
? selectedPage?.sections.find(s => s.id === importFieldTargetSectionId)?.title
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Switch } from '@/components/ui/switch';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import type { OptionColumn } from '../types';
|
||||
|
||||
interface AttributeSubTab {
|
||||
id: string;
|
||||
@@ -19,19 +18,30 @@ interface AttributeSubTab {
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// 유연한 OptionColumn 타입 - 다양한 소스에서 사용 가능
|
||||
interface FlexibleOptionColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
interface ColumnManageDialogProps {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
managingColumnType: string | null;
|
||||
attributeSubTabs: AttributeSubTab[];
|
||||
attributeColumns: Record<string, OptionColumn[]>;
|
||||
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, OptionColumn[]>>>;
|
||||
// FlexibleOptionColumn 또는 OptionColumn 모두 허용
|
||||
attributeColumns: Record<string, FlexibleOptionColumn[]>;
|
||||
setAttributeColumns: React.Dispatch<React.SetStateAction<Record<string, FlexibleOptionColumn[]>>>;
|
||||
newColumnName: string;
|
||||
setNewColumnName: (name: string) => void;
|
||||
newColumnKey: string;
|
||||
setNewColumnKey: (key: string) => void;
|
||||
newColumnType: 'text' | 'number';
|
||||
setNewColumnType: (type: 'text' | 'number') => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
newColumnType: string;
|
||||
setNewColumnType: (type: string) => void;
|
||||
newColumnRequired: boolean;
|
||||
setNewColumnRequired: (required: boolean) => void;
|
||||
}
|
||||
@@ -134,7 +144,7 @@ export function ColumnManageDialog({
|
||||
</div>
|
||||
<div>
|
||||
<Label>타입</Label>
|
||||
<Select value={newColumnType} onValueChange={(value: 'text' | 'number') => setNewColumnType(value)}>
|
||||
<Select value={newColumnType} onValueChange={(value: string) => setNewColumnType(value)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
@@ -162,7 +172,7 @@ export function ColumnManageDialog({
|
||||
}
|
||||
|
||||
if (managingColumnType) {
|
||||
const newColumn: OptionColumn = {
|
||||
const newColumn: FlexibleOptionColumn = {
|
||||
id: `col-${Date.now()}`,
|
||||
name: newColumnName,
|
||||
key: newColumnKey,
|
||||
|
||||
@@ -18,6 +18,9 @@ import { fieldService } from '../services';
|
||||
// 입력 타입 정의
|
||||
export type InputType = 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
|
||||
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원
|
||||
type FieldInputModeType = 'new' | 'existing' | 'custom' | 'master';
|
||||
|
||||
// 텍스트박스 칼럼 타입 (단순 구조)
|
||||
interface OptionColumn {
|
||||
id: string;
|
||||
@@ -25,6 +28,17 @@ interface OptionColumn {
|
||||
key: string;
|
||||
}
|
||||
|
||||
// 유연한 조건부 필드 설정 타입
|
||||
interface FlexibleConditionField {
|
||||
fieldId?: string;
|
||||
fieldKey?: string;
|
||||
fieldName?: string;
|
||||
operator?: string;
|
||||
value?: string;
|
||||
expectedValue?: string;
|
||||
logicOperator?: 'AND' | 'OR';
|
||||
}
|
||||
|
||||
const INPUT_TYPE_OPTIONS: Array<{ value: InputType; label: string }> = [
|
||||
{ value: 'textbox', label: '텍스트박스' },
|
||||
{ value: 'dropdown', label: '드롭다운' },
|
||||
@@ -39,36 +53,42 @@ interface FieldDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingFieldId: number | null;
|
||||
setEditingFieldId: (id: number | null) => void;
|
||||
fieldInputMode: 'custom' | 'master';
|
||||
setFieldInputMode: (mode: 'custom' | 'master') => void;
|
||||
// 'new'/'existing' 또는 'custom'/'master' 모두 지원
|
||||
fieldInputMode: FieldInputModeType;
|
||||
setFieldInputMode: (mode: FieldInputModeType) => void;
|
||||
showMasterFieldList: boolean;
|
||||
setShowMasterFieldList: (show: boolean) => void;
|
||||
selectedMasterFieldId: string;
|
||||
setSelectedMasterFieldId: (id: string) => void;
|
||||
// string 또는 number | null 모두 지원
|
||||
selectedMasterFieldId: string | number | null;
|
||||
setSelectedMasterFieldId: (id: string | number | null) => void;
|
||||
textboxColumns: OptionColumn[];
|
||||
setTextboxColumns: React.Dispatch<React.SetStateAction<OptionColumn[]>>;
|
||||
newFieldConditionEnabled: boolean;
|
||||
setNewFieldConditionEnabled: (enabled: boolean) => void;
|
||||
newFieldConditionTargetType: 'field' | 'section';
|
||||
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
|
||||
newFieldConditionFields: ConditionalFieldConfig[];
|
||||
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<ConditionalFieldConfig[]>>;
|
||||
newFieldConditionSections: string[];
|
||||
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
// 유연한 조건부 필드 설정 - ConditionalFieldConfig 또는 ConditionField 모두 지원
|
||||
newFieldConditionFields: FlexibleConditionField[] | ConditionalFieldConfig[];
|
||||
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<FlexibleConditionField[]>> | React.Dispatch<React.SetStateAction<ConditionalFieldConfig[]>>;
|
||||
// 유연한 섹션 조건 - string[] 또는 ConditionSection[] 모두 지원
|
||||
newFieldConditionSections: string[] | Array<{ sectionId?: string; sectionTitle?: string; operator?: string; value?: string; logicOperator?: 'AND' | 'OR' }>;
|
||||
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>> | React.Dispatch<React.SetStateAction<Array<{ sectionId?: string; sectionTitle?: string; operator?: string; value?: string; logicOperator?: 'AND' | 'OR' }>>>;
|
||||
tempConditionValue: string;
|
||||
setTempConditionValue: (value: string) => void;
|
||||
newFieldName: string;
|
||||
setNewFieldName: (name: string) => void;
|
||||
newFieldKey: string;
|
||||
setNewFieldKey: (key: string) => void;
|
||||
newFieldInputType: InputType;
|
||||
setNewFieldInputType: (type: InputType) => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
newFieldInputType: string;
|
||||
setNewFieldInputType: (type: string) => void;
|
||||
newFieldRequired: boolean;
|
||||
setNewFieldRequired: (required: boolean) => void;
|
||||
newFieldDescription: string;
|
||||
setNewFieldDescription: (description: string) => void;
|
||||
newFieldOptions: string;
|
||||
setNewFieldOptions: (options: string) => void;
|
||||
// string 또는 string[] 모두 지원
|
||||
newFieldOptions: string | string[];
|
||||
setNewFieldOptions: ((options: string | string[]) => void) | React.Dispatch<React.SetStateAction<string[]>> | React.Dispatch<React.SetStateAction<string>>;
|
||||
selectedSectionForField: ItemSection | null;
|
||||
selectedPage: ItemPage | null;
|
||||
itemMasterFields: ItemMasterField[];
|
||||
@@ -125,6 +145,22 @@ export function FieldDialog({
|
||||
}: FieldDialogProps) {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
// 입력 모드 정규화: 'new' → 'custom', 'existing' → 'master'
|
||||
const normalizedInputMode =
|
||||
fieldInputMode === 'new' ? 'custom' :
|
||||
fieldInputMode === 'existing' ? 'master' :
|
||||
fieldInputMode;
|
||||
|
||||
const isCustomMode = normalizedInputMode === 'custom';
|
||||
const isMasterMode = normalizedInputMode === 'master';
|
||||
|
||||
// 옵션을 문자열로 변환하여 처리
|
||||
const optionsString = Array.isArray(newFieldOptions) ? newFieldOptions.join(', ') : newFieldOptions;
|
||||
|
||||
// setNewFieldOptions 래퍼 - union type 호환성 해결
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSetNewFieldOptions = (options: string | string[]) => (setNewFieldOptions as any)(options);
|
||||
|
||||
// fieldService를 사용한 유효성 검사
|
||||
const nameValidation = fieldService.validateFieldName(newFieldName);
|
||||
const keyValidation = fieldService.validateFieldKey(newFieldKey);
|
||||
@@ -151,7 +187,7 @@ export function FieldDialog({
|
||||
setNewFieldKey('');
|
||||
setNewFieldInputType('textbox');
|
||||
setNewFieldRequired(false);
|
||||
setNewFieldOptions('');
|
||||
handleSetNewFieldOptions('');
|
||||
setNewFieldDescription('');
|
||||
};
|
||||
|
||||
@@ -169,7 +205,7 @@ export function FieldDialog({
|
||||
{!editingFieldId && (
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded">
|
||||
<Button
|
||||
variant={fieldInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
variant={isCustomMode ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setFieldInputMode('custom')}
|
||||
className="flex-1"
|
||||
@@ -177,7 +213,7 @@ export function FieldDialog({
|
||||
직접 입력
|
||||
</Button>
|
||||
<Button
|
||||
variant={fieldInputMode === 'master' ? 'default' : 'ghost'}
|
||||
variant={isMasterMode ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFieldInputMode('master');
|
||||
@@ -191,7 +227,7 @@ export function FieldDialog({
|
||||
)}
|
||||
|
||||
{/* 항목 목록 */}
|
||||
{fieldInputMode === 'master' && !editingFieldId && showMasterFieldList && (
|
||||
{isMasterMode && !editingFieldId && showMasterFieldList && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>항목 목록</Label>
|
||||
@@ -225,7 +261,7 @@ export function FieldDialog({
|
||||
setNewFieldRequired(field.properties?.required || false);
|
||||
setNewFieldDescription(field.description || '');
|
||||
// options는 {label, value}[] 배열이므로 label만 추출
|
||||
setNewFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
|
||||
handleSetNewFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
|
||||
if (field.properties?.multiColumn && field.properties?.columnNames) {
|
||||
setTextboxColumns(
|
||||
field.properties.columnNames.map((name: string, idx: number) => ({
|
||||
@@ -271,7 +307,7 @@ export function FieldDialog({
|
||||
)}
|
||||
|
||||
{/* 직접 입력 폼 */}
|
||||
{(fieldInputMode === 'custom' || editingFieldId) && (
|
||||
{(isCustomMode || editingFieldId) && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -321,8 +357,8 @@ export function FieldDialog({
|
||||
<div>
|
||||
<Label>드롭다운 옵션</Label>
|
||||
<Input
|
||||
value={newFieldOptions}
|
||||
onChange={(e) => setNewFieldOptions(e.target.value)}
|
||||
value={optionsString}
|
||||
onChange={(e) => handleSetNewFieldOptions(e.target.value)}
|
||||
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
|
||||
/>
|
||||
</div>
|
||||
@@ -404,18 +440,18 @@ export function FieldDialog({
|
||||
)}
|
||||
|
||||
{/* 조건부 표시 설정 - 모든 입력방식에서 사용 가능 */}
|
||||
{(fieldInputMode === 'custom' || editingFieldId) && (
|
||||
{(isCustomMode || editingFieldId) && (
|
||||
<ConditionalDisplayUI
|
||||
newFieldConditionEnabled={newFieldConditionEnabled}
|
||||
setNewFieldConditionEnabled={setNewFieldConditionEnabled}
|
||||
newFieldConditionTargetType={newFieldConditionTargetType}
|
||||
setNewFieldConditionTargetType={setNewFieldConditionTargetType}
|
||||
newFieldConditionFields={newFieldConditionFields}
|
||||
setNewFieldConditionFields={setNewFieldConditionFields}
|
||||
newFieldConditionFields={newFieldConditionFields as ConditionalFieldConfig[]}
|
||||
setNewFieldConditionFields={setNewFieldConditionFields as (value: ConditionalFieldConfig[] | ((prev: ConditionalFieldConfig[]) => ConditionalFieldConfig[])) => void}
|
||||
tempConditionValue={tempConditionValue}
|
||||
setTempConditionValue={setTempConditionValue}
|
||||
newFieldKey={newFieldKey}
|
||||
newFieldInputType={newFieldInputType}
|
||||
newFieldInputType={newFieldInputType as InputType}
|
||||
selectedPage={selectedPage}
|
||||
selectedSectionForField={selectedSectionForField}
|
||||
editingFieldId={editingFieldId}
|
||||
@@ -438,7 +474,7 @@ export function FieldDialog({
|
||||
});
|
||||
setIsSubmitted(true);
|
||||
// 2025-11-28: field_key validation 추가
|
||||
const shouldValidate = fieldInputMode === 'custom' || editingFieldId;
|
||||
const shouldValidate = isCustomMode || editingFieldId;
|
||||
console.log('[FieldDialog] 🔵 shouldValidate:', shouldValidate);
|
||||
if (shouldValidate && (isNameEmpty || isKeyEmpty || isKeyInvalid)) {
|
||||
console.log('[FieldDialog] ❌ 유효성 검사 실패로 return');
|
||||
|
||||
@@ -27,45 +27,74 @@ const INPUT_TYPE_OPTIONS = [
|
||||
{ value: 'textarea', label: '텍스트영역' }
|
||||
];
|
||||
|
||||
// 유연한 조건부 필드 타입
|
||||
interface FlexibleConditionField {
|
||||
fieldId?: string;
|
||||
fieldKey?: string;
|
||||
fieldName?: string;
|
||||
operator?: string;
|
||||
value?: string;
|
||||
expectedValue?: string;
|
||||
logicOperator?: 'AND' | 'OR';
|
||||
}
|
||||
|
||||
// 유연한 조건부 섹션 타입
|
||||
interface FlexibleConditionSection {
|
||||
sectionId?: string;
|
||||
sectionTitle?: string;
|
||||
operator?: string;
|
||||
value?: string;
|
||||
logicOperator?: 'AND' | 'OR';
|
||||
}
|
||||
|
||||
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원
|
||||
type FieldInputModeType = 'new' | 'existing' | 'custom' | 'master';
|
||||
|
||||
interface FieldDrawerProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
editingFieldId: number | null;
|
||||
setEditingFieldId: (id: number | null) => void;
|
||||
fieldInputMode: 'custom' | 'master';
|
||||
setFieldInputMode: (mode: 'custom' | 'master') => void;
|
||||
// 'new'/'existing' 또는 'custom'/'master' 모두 지원
|
||||
fieldInputMode: FieldInputModeType;
|
||||
setFieldInputMode: (mode: FieldInputModeType) => void;
|
||||
showMasterFieldList: boolean;
|
||||
setShowMasterFieldList: (show: boolean) => void;
|
||||
selectedMasterFieldId: string;
|
||||
setSelectedMasterFieldId: (id: string) => void;
|
||||
// string 또는 number | null 모두 지원
|
||||
selectedMasterFieldId: string | number | null;
|
||||
setSelectedMasterFieldId: (id: string | number | null) => void;
|
||||
textboxColumns: OptionColumn[];
|
||||
setTextboxColumns: React.Dispatch<React.SetStateAction<OptionColumn[]>>;
|
||||
newFieldConditionEnabled: boolean;
|
||||
setNewFieldConditionEnabled: (enabled: boolean) => void;
|
||||
newFieldConditionTargetType: 'field' | 'section';
|
||||
setNewFieldConditionTargetType: (type: 'field' | 'section') => void;
|
||||
newFieldConditionFields: Array<{ fieldKey: string; expectedValue: string }>;
|
||||
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<Array<{ fieldKey: string; expectedValue: string }>>>;
|
||||
newFieldConditionSections: string[];
|
||||
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
// 유연한 조건부 필드 타입
|
||||
newFieldConditionFields: FlexibleConditionField[] | Array<{ fieldKey: string; expectedValue: string }>;
|
||||
setNewFieldConditionFields: React.Dispatch<React.SetStateAction<FlexibleConditionField[]>> | React.Dispatch<React.SetStateAction<Array<{ fieldKey: string; expectedValue: string }>>>;
|
||||
// 유연한 조건부 섹션 타입
|
||||
newFieldConditionSections: string[] | FlexibleConditionSection[];
|
||||
setNewFieldConditionSections: React.Dispatch<React.SetStateAction<string[]>> | React.Dispatch<React.SetStateAction<FlexibleConditionSection[]>>;
|
||||
tempConditionValue: string;
|
||||
setTempConditionValue: (value: string) => void;
|
||||
newFieldName: string;
|
||||
setNewFieldName: (name: string) => void;
|
||||
newFieldKey: string;
|
||||
setNewFieldKey: (key: string) => void;
|
||||
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
setNewFieldInputType: (type: any) => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
newFieldInputType: string;
|
||||
setNewFieldInputType: (type: string) => void;
|
||||
newFieldRequired: boolean;
|
||||
setNewFieldRequired: (required: boolean) => void;
|
||||
newFieldDescription: string;
|
||||
setNewFieldDescription: (description: string) => void;
|
||||
newFieldOptions: string;
|
||||
setNewFieldOptions: (options: string) => void;
|
||||
// string | string[] 모두 지원
|
||||
newFieldOptions: string | string[];
|
||||
setNewFieldOptions: ((options: string) => void) | React.Dispatch<React.SetStateAction<string[]>>;
|
||||
selectedSectionForField: ItemSection | null;
|
||||
selectedPage: ItemPage | null;
|
||||
itemMasterFields: ItemMasterField[];
|
||||
handleAddField: () => Promise<void>;
|
||||
handleAddField: () => void | Promise<void>;
|
||||
setIsColumnDialogOpen: (open: boolean) => void;
|
||||
setEditingColumnId: (id: string | null) => void;
|
||||
setColumnName: (name: string) => void;
|
||||
@@ -116,6 +145,30 @@ export function FieldDrawer({
|
||||
setColumnName,
|
||||
setColumnKey
|
||||
}: FieldDrawerProps) {
|
||||
// 입력 모드 정규화: 'new' → 'custom', 'existing' → 'master'
|
||||
const normalizedInputMode =
|
||||
fieldInputMode === 'new' ? 'custom' :
|
||||
fieldInputMode === 'existing' ? 'master' :
|
||||
fieldInputMode;
|
||||
|
||||
const isCustomMode = normalizedInputMode === 'custom';
|
||||
const isMasterMode = normalizedInputMode === 'master';
|
||||
|
||||
// 옵션을 문자열로 변환하여 처리
|
||||
const optionsString = Array.isArray(newFieldOptions) ? newFieldOptions.join(', ') : newFieldOptions;
|
||||
|
||||
// setNewFieldOptions 래퍼 - union type 호환성 해결
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSetNewFieldOptions = (options: string) => (setNewFieldOptions as any)(options);
|
||||
|
||||
// setNewFieldConditionFields 래퍼 - union type 호환성 해결
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSetNewFieldConditionFields = (updater: any) => (setNewFieldConditionFields as any)(updater);
|
||||
|
||||
// setNewFieldConditionSections 래퍼 - union type 호환성 해결
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSetNewFieldConditionSections = (updater: any) => (setNewFieldConditionSections as any)(updater);
|
||||
|
||||
const handleClose = () => {
|
||||
onOpenChange(false);
|
||||
setEditingFieldId(null);
|
||||
@@ -145,7 +198,7 @@ export function FieldDrawer({
|
||||
{!editingFieldId && (
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded">
|
||||
<Button
|
||||
variant={fieldInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
variant={isCustomMode ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setFieldInputMode('custom')}
|
||||
className="flex-1"
|
||||
@@ -153,7 +206,7 @@ export function FieldDrawer({
|
||||
직접 입력
|
||||
</Button>
|
||||
<Button
|
||||
variant={fieldInputMode === 'master' ? 'default' : 'ghost'}
|
||||
variant={isMasterMode ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFieldInputMode('master');
|
||||
@@ -167,7 +220,7 @@ export function FieldDrawer({
|
||||
)}
|
||||
|
||||
{/* 항목 목록 */}
|
||||
{fieldInputMode === 'master' && !editingFieldId && showMasterFieldList && (
|
||||
{isMasterMode && !editingFieldId && showMasterFieldList && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>항목 목록</Label>
|
||||
@@ -200,7 +253,7 @@ export function FieldDrawer({
|
||||
setNewFieldInputType(field.field_type);
|
||||
setNewFieldRequired((field.properties as any)?.required ?? false);
|
||||
setNewFieldDescription(field.description || '');
|
||||
setNewFieldOptions(field.options?.map(o => o.value).join(', ') || '');
|
||||
handleSetNewFieldOptions(field.options?.map(o => o.value).join(', ') || '');
|
||||
const props = field.properties as any;
|
||||
if (props?.multiColumn && props?.columnNames) {
|
||||
setTextboxColumns(
|
||||
@@ -247,7 +300,7 @@ export function FieldDrawer({
|
||||
)}
|
||||
|
||||
{/* 직접 입력 폼 */}
|
||||
{(fieldInputMode === 'custom' || editingFieldId) && (
|
||||
{(isCustomMode || editingFieldId) && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
@@ -286,8 +339,8 @@ export function FieldDrawer({
|
||||
<div>
|
||||
<Label>드롭다운 옵션</Label>
|
||||
<Input
|
||||
value={newFieldOptions}
|
||||
onChange={(e) => setNewFieldOptions(e.target.value)}
|
||||
value={optionsString}
|
||||
onChange={(e) => handleSetNewFieldOptions(e.target.value)}
|
||||
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
|
||||
/>
|
||||
</div>
|
||||
@@ -369,7 +422,7 @@ export function FieldDrawer({
|
||||
)}
|
||||
|
||||
{/* 조건부 표시 설정 - 모든 입력방식에서 사용 가능 */}
|
||||
{(fieldInputMode === 'custom' || editingFieldId) && (
|
||||
{(isCustomMode || editingFieldId) && (
|
||||
<div className="border-t pt-4 space-y-3">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -450,7 +503,7 @@ export function FieldDrawer({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
|
||||
handleSetNewFieldConditionFields((prev: FlexibleConditionField[]) => prev.filter((_: FlexibleConditionField, i: number) => i !== index));
|
||||
toast.success('조건이 제거되었습니다.');
|
||||
}}
|
||||
className="h-8 w-8 p-0"
|
||||
@@ -481,7 +534,7 @@ export function FieldDrawer({
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (tempConditionValue) {
|
||||
setNewFieldConditionFields(prev => [...prev, {
|
||||
handleSetNewFieldConditionFields((prev: FlexibleConditionField[]) => [...prev, {
|
||||
fieldKey: newFieldKey, // 현재 항목 자신의 키를 사용
|
||||
expectedValue: tempConditionValue
|
||||
}]);
|
||||
@@ -533,7 +586,7 @@ export function FieldDrawer({
|
||||
onClick={() => {
|
||||
if (tempConditionValue) {
|
||||
if (!newFieldConditionFields.find(f => f.expectedValue === tempConditionValue)) {
|
||||
setNewFieldConditionFields(prev => [...prev, {
|
||||
handleSetNewFieldConditionFields((prev: FlexibleConditionField[]) => [...prev, {
|
||||
fieldKey: newFieldKey,
|
||||
expectedValue: tempConditionValue
|
||||
}]);
|
||||
@@ -563,7 +616,7 @@ export function FieldDrawer({
|
||||
{condition.expectedValue}
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewFieldConditionFields(prev => prev.filter((_, i) => i !== index));
|
||||
handleSetNewFieldConditionFields((prev: FlexibleConditionField[]) => prev.filter((_: FlexibleConditionField, i: number) => i !== index));
|
||||
toast.success('조건값이 제거되었습니다.');
|
||||
}}
|
||||
className="ml-1 hover:text-red-500"
|
||||
@@ -593,9 +646,9 @@ export function FieldDrawer({
|
||||
onChange={(e) => {
|
||||
const sectionIdStr = String(section.id);
|
||||
if (e.target.checked) {
|
||||
setNewFieldConditionSections(prev => [...prev, sectionIdStr]);
|
||||
handleSetNewFieldConditionSections((prev: string[]) => [...prev, sectionIdStr]);
|
||||
} else {
|
||||
setNewFieldConditionSections(prev => prev.filter(id => id !== sectionIdStr));
|
||||
handleSetNewFieldConditionSections((prev: string[]) => prev.filter((id: string) => id !== sectionIdStr));
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
|
||||
@@ -16,8 +16,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FormInput, Search, Info, Loader2, Hash, Calendar, CheckSquare, ChevronDown, Type, AlignLeft, Database } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { ItemField, ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
import type { FieldUsageResponse } from '@/types/item-master-api';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface ImportFieldDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -109,6 +111,7 @@ export function ImportFieldDialog({
|
||||
const usage = await onGetUsage(selectedFieldId);
|
||||
setUsageInfo(usage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load usage info:', error);
|
||||
setUsageInfo(null);
|
||||
} finally {
|
||||
@@ -154,10 +157,7 @@ export function ImportFieldDialog({
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">필드 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
<ContentLoadingSpinner text="필드 목록을 불러오는 중..." />
|
||||
) : filteredFields.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<FormInput className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
|
||||
@@ -7,8 +7,10 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Package, Folder, Search, Info, Loader2 } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import type { ItemSection } from '@/contexts/ItemMasterContext';
|
||||
import type { SectionUsageResponse } from '@/types/item-master-api';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface ImportSectionDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -61,6 +63,7 @@ export function ImportSectionDialog({
|
||||
const usage = await onGetUsage(selectedSectionId);
|
||||
setUsageInfo(usage);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('Failed to load usage info:', error);
|
||||
setUsageInfo(null);
|
||||
} finally {
|
||||
@@ -106,10 +109,7 @@ export function ImportSectionDialog({
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">섹션 목록을 불러오는 중...</span>
|
||||
</div>
|
||||
<ContentLoadingSpinner text="섹션 목록을 불러오는 중..." />
|
||||
) : filteredSections.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Folder className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
|
||||
@@ -10,8 +10,9 @@ interface LoadTemplateDialogProps {
|
||||
isLoadTemplateDialogOpen: boolean;
|
||||
setIsLoadTemplateDialogOpen: (open: boolean) => void;
|
||||
sectionTemplates: SectionTemplate[];
|
||||
selectedTemplateId: string | null;
|
||||
setSelectedTemplateId: (id: string | null) => void;
|
||||
// string 또는 number | null 모두 지원
|
||||
selectedTemplateId: string | number | null;
|
||||
setSelectedTemplateId: (id: string | number | null) => void;
|
||||
handleLoadTemplate: () => void;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,24 +23,27 @@ interface MasterFieldDialogProps {
|
||||
setNewMasterFieldName: (name: string) => void;
|
||||
newMasterFieldKey: string;
|
||||
setNewMasterFieldKey: (key: string) => void;
|
||||
newMasterFieldInputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
setNewMasterFieldInputType: (type: any) => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
newMasterFieldInputType: string;
|
||||
setNewMasterFieldInputType: (type: string) => void;
|
||||
newMasterFieldRequired: boolean;
|
||||
setNewMasterFieldRequired: (required: boolean) => void;
|
||||
newMasterFieldCategory: string;
|
||||
setNewMasterFieldCategory: (category: string) => void;
|
||||
newMasterFieldDescription: string;
|
||||
setNewMasterFieldDescription: (description: string) => void;
|
||||
newMasterFieldOptions: string;
|
||||
setNewMasterFieldOptions: (options: string) => void;
|
||||
newMasterFieldAttributeType: 'custom' | 'unit' | 'material' | 'surface';
|
||||
setNewMasterFieldAttributeType: (type: 'custom' | 'unit' | 'material' | 'surface') => void;
|
||||
// string 또는 string[] 모두 지원
|
||||
newMasterFieldOptions: string | string[];
|
||||
setNewMasterFieldOptions: ((options: string) => void) | React.Dispatch<React.SetStateAction<string[]>>;
|
||||
// string 타입으로 유연하게 처리
|
||||
newMasterFieldAttributeType: string;
|
||||
setNewMasterFieldAttributeType: (type: string) => void;
|
||||
newMasterFieldMultiColumn: boolean;
|
||||
setNewMasterFieldMultiColumn: (multi: boolean) => void;
|
||||
newMasterFieldColumnCount: number;
|
||||
setNewMasterFieldColumnCount: (count: number) => void;
|
||||
newMasterFieldColumnNames: string[];
|
||||
setNewMasterFieldColumnNames: (names: string[]) => void;
|
||||
setNewMasterFieldColumnNames: ((names: string[]) => void) | React.Dispatch<React.SetStateAction<string[]>>;
|
||||
handleUpdateMasterField: () => void;
|
||||
handleAddMasterField: () => void;
|
||||
}
|
||||
@@ -77,6 +80,13 @@ export function MasterFieldDialog({
|
||||
}: MasterFieldDialogProps) {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
// 옵션을 문자열로 변환하여 처리
|
||||
const optionsString = Array.isArray(newMasterFieldOptions) ? newMasterFieldOptions.join(', ') : newMasterFieldOptions;
|
||||
|
||||
// setNewMasterFieldOptions 래퍼 - union type 호환성 해결
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSetNewMasterFieldOptions = (options: string) => (setNewMasterFieldOptions as any)(options);
|
||||
|
||||
// 2025-12-01: masterFieldService 사용으로 유효성 검사 중앙화
|
||||
const nameValidation = masterFieldService.validateFieldName(newMasterFieldName);
|
||||
const keyValidation = masterFieldService.validateFieldKey(newMasterFieldKey);
|
||||
@@ -93,7 +103,7 @@ export function MasterFieldDialog({
|
||||
setNewMasterFieldRequired(false);
|
||||
setNewMasterFieldCategory('공통');
|
||||
setNewMasterFieldDescription('');
|
||||
setNewMasterFieldOptions('');
|
||||
handleSetNewMasterFieldOptions('');
|
||||
setNewMasterFieldAttributeType('custom');
|
||||
setNewMasterFieldMultiColumn(false);
|
||||
setNewMasterFieldColumnCount(2);
|
||||
@@ -267,8 +277,8 @@ export function MasterFieldDialog({
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
value={newMasterFieldOptions}
|
||||
onChange={(e) => setNewMasterFieldOptions(e.target.value)}
|
||||
value={optionsString}
|
||||
onChange={(e) => handleSetNewMasterFieldOptions(e.target.value)}
|
||||
placeholder="제품,부품,원자재 (쉼표로 구분)"
|
||||
disabled={newMasterFieldAttributeType !== 'custom'}
|
||||
className="min-h-[80px]"
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
|
||||
// 유연한 타입 정의 - 다양한 소스에서 사용 가능
|
||||
interface OptionColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -33,12 +34,14 @@ interface OptionDialogProps {
|
||||
setNewOptionLabel: (label: string) => void;
|
||||
newOptionColumnValues: Record<string, string>;
|
||||
setNewOptionColumnValues: (values: Record<string, string>) => void;
|
||||
newOptionInputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
setNewOptionInputType: (type: any) => void;
|
||||
// string 타입으로 유연하게 처리 (textbox, number, dropdown, checkbox, date, textarea)
|
||||
newOptionInputType: string;
|
||||
setNewOptionInputType: (type: string) => void;
|
||||
newOptionRequired: boolean;
|
||||
setNewOptionRequired: (required: boolean) => void;
|
||||
newOptionOptions: string;
|
||||
setNewOptionOptions: (options: string) => void;
|
||||
// string[] 또는 string 모두 지원
|
||||
newOptionOptions: string | string[];
|
||||
setNewOptionOptions: (options: string | string[]) => void;
|
||||
newOptionPlaceholder: string;
|
||||
setNewOptionPlaceholder: (placeholder: string) => void;
|
||||
newOptionDefaultValue: string;
|
||||
@@ -75,10 +78,13 @@ export function OptionDialog({
|
||||
}: OptionDialogProps) {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
// 옵션을 문자열로 변환하여 처리
|
||||
const optionsString = Array.isArray(newOptionOptions) ? newOptionOptions.join(', ') : newOptionOptions;
|
||||
|
||||
// 유효성 검사
|
||||
const isValueEmpty = !newOptionValue.trim();
|
||||
const isLabelEmpty = !newOptionLabel.trim();
|
||||
const isDropdownOptionsEmpty = newOptionInputType === 'dropdown' && !newOptionOptions.trim();
|
||||
const isDropdownOptionsEmpty = newOptionInputType === 'dropdown' && !optionsString.trim();
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
@@ -176,7 +182,7 @@ export function OptionDialog({
|
||||
드롭다운 옵션 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={newOptionOptions}
|
||||
value={optionsString}
|
||||
onChange={(e) => setNewOptionOptions(e.target.value)}
|
||||
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
|
||||
className={isSubmitted && isDropdownOptionsEmpty ? 'border-red-500 focus-visible:ring-red-500' : ''}
|
||||
|
||||
@@ -9,6 +9,9 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
import { FileText, Package, Check } from 'lucide-react';
|
||||
import type { SectionTemplate } from '@/contexts/ItemMasterContext';
|
||||
|
||||
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'template' 모두 지원
|
||||
type InputModeType = 'new' | 'existing' | 'custom' | 'template';
|
||||
|
||||
interface SectionDialogProps {
|
||||
isSectionDialogOpen: boolean;
|
||||
setIsSectionDialogOpen: (open: boolean) => void;
|
||||
@@ -19,13 +22,13 @@ interface SectionDialogProps {
|
||||
newSectionDescription: string;
|
||||
setNewSectionDescription: (description: string) => void;
|
||||
handleAddSection: () => void;
|
||||
// 템플릿 선택 관련 props
|
||||
sectionInputMode: 'custom' | 'template';
|
||||
setSectionInputMode: (mode: 'custom' | 'template') => void;
|
||||
// 템플릿 선택 관련 props - 유연한 타입 지원
|
||||
sectionInputMode: InputModeType;
|
||||
setSectionInputMode: (mode: InputModeType) => void;
|
||||
sectionTemplates: SectionTemplate[];
|
||||
selectedTemplateId: number | null;
|
||||
setSelectedTemplateId: (id: number | null) => void;
|
||||
handleLinkTemplate: (template: SectionTemplate) => void;
|
||||
handleLinkTemplate: (template: SectionTemplate) => void | Promise<void> | ((template?: SectionTemplate) => void | Promise<void>);
|
||||
}
|
||||
|
||||
export function SectionDialog({
|
||||
@@ -47,6 +50,15 @@ export function SectionDialog({
|
||||
}: SectionDialogProps) {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
// 입력 모드 정규화: 'new' → 'custom', 'existing' → 'template'
|
||||
const normalizedInputMode =
|
||||
sectionInputMode === 'new' ? 'custom' :
|
||||
sectionInputMode === 'existing' ? 'template' :
|
||||
sectionInputMode;
|
||||
|
||||
const isCustomMode = normalizedInputMode === 'custom';
|
||||
const isTemplateMode = normalizedInputMode === 'template';
|
||||
|
||||
// 유효성 검사
|
||||
const isTitleEmpty = !newSectionTitle.trim();
|
||||
|
||||
@@ -62,7 +74,7 @@ export function SectionDialog({
|
||||
|
||||
const handleSubmit = () => {
|
||||
setIsSubmitted(true);
|
||||
if (sectionInputMode === 'custom' && !isTitleEmpty) {
|
||||
if (isCustomMode && !isTitleEmpty) {
|
||||
handleAddSection();
|
||||
setIsSubmitted(false);
|
||||
}
|
||||
@@ -152,7 +164,7 @@ export function SectionDialog({
|
||||
{/* 2. 입력 모드 선택 */}
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded">
|
||||
<Button
|
||||
variant={sectionInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
variant={isCustomMode ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSectionInputMode('custom');
|
||||
@@ -165,7 +177,7 @@ export function SectionDialog({
|
||||
직접 입력
|
||||
</Button>
|
||||
<Button
|
||||
variant={sectionInputMode === 'template' ? 'default' : 'ghost'}
|
||||
variant={isTemplateMode ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSectionInputMode('template');
|
||||
@@ -180,7 +192,7 @@ export function SectionDialog({
|
||||
</div>
|
||||
|
||||
{/* 3. 템플릿 목록 - 선택된 섹션 타입에 따라 필터링 */}
|
||||
{sectionInputMode === 'template' && (
|
||||
{isTemplateMode && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[250px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label className="text-sm font-medium">
|
||||
@@ -248,7 +260,7 @@ export function SectionDialog({
|
||||
)}
|
||||
|
||||
{/* 4. 직접 입력 폼 */}
|
||||
{sectionInputMode === 'custom' && (
|
||||
{isCustomMode && (
|
||||
<>
|
||||
<div>
|
||||
<Label>섹션 제목 *</Label>
|
||||
@@ -283,7 +295,7 @@ export function SectionDialog({
|
||||
)}
|
||||
|
||||
{/* 5. 선택된 템플릿 정보 표시 */}
|
||||
{sectionInputMode === 'template' && selectedTemplateId && (
|
||||
{isTemplateMode && selectedTemplateId && (
|
||||
<div className="bg-green-50 p-3 rounded-md border border-green-200">
|
||||
<p className="text-sm text-green-700">
|
||||
<strong>선택된 템플릿:</strong> "{newSectionTitle}"을(를) 페이지에 연결합니다.
|
||||
@@ -297,7 +309,7 @@ export function SectionDialog({
|
||||
<Button variant="outline" onClick={handleClose} className="w-full sm:w-auto">
|
||||
취소
|
||||
</Button>
|
||||
{sectionInputMode === 'template' && selectedTemplateId ? (
|
||||
{isTemplateMode && selectedTemplateId ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
const template = sectionTemplates.find(t => t.id === selectedTemplateId);
|
||||
@@ -311,7 +323,7 @@ export function SectionDialog({
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="w-full sm:w-auto"
|
||||
disabled={sectionInputMode === 'template' && !selectedTemplateId}
|
||||
disabled={isTemplateMode && !selectedTemplateId}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
|
||||
@@ -24,8 +24,9 @@ interface SectionTemplateDialogProps {
|
||||
setNewSectionTemplateTitle: (title: string) => void;
|
||||
newSectionTemplateDescription: string;
|
||||
setNewSectionTemplateDescription: (description: string) => void;
|
||||
newSectionTemplateCategory: string[];
|
||||
setNewSectionTemplateCategory: (category: string[]) => void;
|
||||
// string 또는 string[] 모두 지원
|
||||
newSectionTemplateCategory: string | string[];
|
||||
setNewSectionTemplateCategory: (category: string | string[]) => void;
|
||||
newSectionTemplateType: 'fields' | 'bom';
|
||||
setNewSectionTemplateType: (type: 'fields' | 'bom') => void;
|
||||
handleUpdateSectionTemplate: () => void;
|
||||
@@ -50,6 +51,11 @@ export function SectionTemplateDialog({
|
||||
}: SectionTemplateDialogProps) {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
// 카테고리 정규화: string → string[] 변환
|
||||
const categoryArray = Array.isArray(newSectionTemplateCategory)
|
||||
? newSectionTemplateCategory
|
||||
: newSectionTemplateCategory ? [newSectionTemplateCategory] : [];
|
||||
|
||||
// 유효성 검사
|
||||
const isTitleEmpty = !newSectionTemplateTitle.trim();
|
||||
|
||||
@@ -140,12 +146,12 @@ export function SectionTemplateDialog({
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`cat-${type.value}`}
|
||||
checked={newSectionTemplateCategory.includes(type.value)}
|
||||
checked={categoryArray.includes(type.value)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setNewSectionTemplateCategory([...newSectionTemplateCategory, type.value]);
|
||||
setNewSectionTemplateCategory([...categoryArray, type.value]);
|
||||
} else {
|
||||
setNewSectionTemplateCategory(newSectionTemplateCategory.filter(c => c !== type.value));
|
||||
setNewSectionTemplateCategory(categoryArray.filter(c => c !== type.value));
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
|
||||
@@ -21,21 +21,27 @@ const INPUT_TYPE_OPTIONS = [
|
||||
{ value: 'textarea', label: '긴 텍스트' },
|
||||
];
|
||||
|
||||
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원
|
||||
type TemplateFieldInputModeType = 'new' | 'existing' | 'custom' | 'master';
|
||||
|
||||
interface TemplateFieldDialogProps {
|
||||
isTemplateFieldDialogOpen: boolean;
|
||||
setIsTemplateFieldDialogOpen: (open: boolean) => void;
|
||||
editingTemplateFieldId: number | null;
|
||||
setEditingTemplateFieldId: (id: number | null) => void;
|
||||
// string 또는 number | null 모두 지원
|
||||
editingTemplateFieldId: string | number | null;
|
||||
setEditingTemplateFieldId: ((id: string | null) => void) | ((id: number | null) => void);
|
||||
templateFieldName: string;
|
||||
setTemplateFieldName: (name: string) => void;
|
||||
templateFieldKey: string;
|
||||
setTemplateFieldKey: (key: string) => void;
|
||||
templateFieldInputType: 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
setTemplateFieldInputType: (type: any) => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
templateFieldInputType: string;
|
||||
setTemplateFieldInputType: (type: string) => void;
|
||||
templateFieldRequired: boolean;
|
||||
setTemplateFieldRequired: (required: boolean) => void;
|
||||
templateFieldOptions: string;
|
||||
setTemplateFieldOptions: (options: string) => void;
|
||||
// string 또는 string[] 모두 지원
|
||||
templateFieldOptions: string | string[];
|
||||
setTemplateFieldOptions: ((options: string) => void) | React.Dispatch<React.SetStateAction<string[]>>;
|
||||
templateFieldDescription: string;
|
||||
setTemplateFieldDescription: (description: string) => void;
|
||||
templateFieldMultiColumn: boolean;
|
||||
@@ -43,16 +49,17 @@ interface TemplateFieldDialogProps {
|
||||
templateFieldColumnCount: number;
|
||||
setTemplateFieldColumnCount: (count: number) => void;
|
||||
templateFieldColumnNames: string[];
|
||||
setTemplateFieldColumnNames: (names: string[]) => void;
|
||||
setTemplateFieldColumnNames: ((names: string[]) => void) | React.Dispatch<React.SetStateAction<string[]>>;
|
||||
handleAddTemplateField: () => void | Promise<void>;
|
||||
// 항목 관련 props
|
||||
// 항목 관련 props - 유연한 타입 지원
|
||||
itemMasterFields?: ItemMasterField[];
|
||||
templateFieldInputMode?: 'custom' | 'master';
|
||||
setTemplateFieldInputMode?: (mode: 'custom' | 'master') => void;
|
||||
templateFieldInputMode?: TemplateFieldInputModeType;
|
||||
setTemplateFieldInputMode?: (mode: TemplateFieldInputModeType) => void;
|
||||
showMasterFieldList?: boolean;
|
||||
setShowMasterFieldList?: (show: boolean) => void;
|
||||
selectedMasterFieldId?: string;
|
||||
setSelectedMasterFieldId?: (id: string) => void;
|
||||
// string 또는 number | null 모두 지원
|
||||
selectedMasterFieldId?: string | number | null;
|
||||
setSelectedMasterFieldId?: ((id: string) => void) | ((id: number | null) => void);
|
||||
}
|
||||
|
||||
export function TemplateFieldDialog({
|
||||
@@ -90,6 +97,23 @@ export function TemplateFieldDialog({
|
||||
}: TemplateFieldDialogProps) {
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
// 입력 모드 정규화: 'new' → 'custom', 'existing' → 'master'
|
||||
const normalizedInputMode =
|
||||
templateFieldInputMode === 'new' ? 'custom' :
|
||||
templateFieldInputMode === 'existing' ? 'master' :
|
||||
templateFieldInputMode;
|
||||
|
||||
// 옵션을 문자열로 변환하여 처리
|
||||
const optionsString = Array.isArray(templateFieldOptions) ? templateFieldOptions.join(', ') : templateFieldOptions;
|
||||
|
||||
// 래퍼 함수들 - union type 호환성 해결
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSetTemplateFieldOptions = (options: string) => (setTemplateFieldOptions as any)(options);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSetEditingTemplateFieldId = (id: string | null) => (setEditingTemplateFieldId as any)(id);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleSetSelectedMasterFieldId = (id: string) => setSelectedMasterFieldId && (setSelectedMasterFieldId as any)(id);
|
||||
|
||||
// 유효성 검사
|
||||
const isNameEmpty = !templateFieldName.trim();
|
||||
const isKeyEmpty = !templateFieldKey.trim();
|
||||
@@ -97,12 +121,12 @@ export function TemplateFieldDialog({
|
||||
const handleClose = () => {
|
||||
setIsSubmitted(false);
|
||||
setIsTemplateFieldDialogOpen(false);
|
||||
setEditingTemplateFieldId(null);
|
||||
handleSetEditingTemplateFieldId(null);
|
||||
setTemplateFieldName('');
|
||||
setTemplateFieldKey('');
|
||||
setTemplateFieldInputType('textbox');
|
||||
setTemplateFieldRequired(false);
|
||||
setTemplateFieldOptions('');
|
||||
handleSetTemplateFieldOptions('');
|
||||
setTemplateFieldDescription('');
|
||||
setTemplateFieldMultiColumn(false);
|
||||
setTemplateFieldColumnCount(2);
|
||||
@@ -110,18 +134,18 @@ export function TemplateFieldDialog({
|
||||
// 항목 관련 상태 초기화
|
||||
setTemplateFieldInputMode?.('custom');
|
||||
setShowMasterFieldList?.(false);
|
||||
setSelectedMasterFieldId?.('');
|
||||
handleSetSelectedMasterFieldId('');
|
||||
};
|
||||
|
||||
const handleSelectMasterField = (field: ItemMasterField) => {
|
||||
setSelectedMasterFieldId?.(String(field.id));
|
||||
handleSetSelectedMasterFieldId(String(field.id));
|
||||
setTemplateFieldName(field.field_name);
|
||||
setTemplateFieldKey(field.id.toString());
|
||||
setTemplateFieldInputType(field.field_type);
|
||||
setTemplateFieldRequired(field.properties?.required || false);
|
||||
setTemplateFieldDescription(field.description || '');
|
||||
// options는 {label, value}[] 배열이므로 label만 추출
|
||||
setTemplateFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
|
||||
handleSetTemplateFieldOptions(field.options?.map(opt => opt.label).join(', ') || '');
|
||||
if (field.properties?.multiColumn && field.properties?.columnNames) {
|
||||
setTemplateFieldMultiColumn(true);
|
||||
setTemplateFieldColumnCount(field.properties.columnNames.length);
|
||||
@@ -145,7 +169,7 @@ export function TemplateFieldDialog({
|
||||
{!editingTemplateFieldId && setTemplateFieldInputMode && (
|
||||
<div className="flex gap-2 p-1 bg-gray-100 rounded">
|
||||
<Button
|
||||
variant={templateFieldInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
variant={normalizedInputMode === 'custom' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setTemplateFieldInputMode('custom')}
|
||||
className="flex-1"
|
||||
@@ -153,7 +177,7 @@ export function TemplateFieldDialog({
|
||||
직접 입력
|
||||
</Button>
|
||||
<Button
|
||||
variant={templateFieldInputMode === 'master' ? 'default' : 'ghost'}
|
||||
variant={normalizedInputMode === 'master' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTemplateFieldInputMode('master');
|
||||
@@ -167,7 +191,7 @@ export function TemplateFieldDialog({
|
||||
)}
|
||||
|
||||
{/* 항목 목록 */}
|
||||
{templateFieldInputMode === 'master' && !editingTemplateFieldId && showMasterFieldList && (
|
||||
{normalizedInputMode === 'master' && !editingTemplateFieldId && showMasterFieldList && (
|
||||
<div className="border rounded p-3 space-y-2 max-h-[400px] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label>항목 목록</Label>
|
||||
@@ -276,8 +300,8 @@ export function TemplateFieldDialog({
|
||||
<div>
|
||||
<Label>드롭다운 옵션</Label>
|
||||
<Input
|
||||
value={templateFieldOptions}
|
||||
onChange={(e) => setTemplateFieldOptions(e.target.value)}
|
||||
value={optionsString}
|
||||
onChange={(e) => handleSetTemplateFieldOptions(e.target.value)}
|
||||
placeholder="옵션1, 옵션2, 옵션3 (쉼표로 구분)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,12 +30,14 @@ export interface UseAttributeManagementReturn {
|
||||
setNewOptionLabel: (label: string) => void;
|
||||
newOptionColumnValues: Record<string, string>;
|
||||
setNewOptionColumnValues: React.Dispatch<React.SetStateAction<Record<string, string>>>;
|
||||
newOptionInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
setNewOptionInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
|
||||
// string 타입으로 유연하게 처리 (다양한 컴포넌트에서 사용)
|
||||
newOptionInputType: string;
|
||||
setNewOptionInputType: (type: string) => void;
|
||||
newOptionRequired: boolean;
|
||||
setNewOptionRequired: (required: boolean) => void;
|
||||
newOptionOptions: string;
|
||||
setNewOptionOptions: (options: string) => void;
|
||||
// string 또는 string[] 모두 지원 (다양한 컴포넌트에서 사용)
|
||||
newOptionOptions: string | string[];
|
||||
setNewOptionOptions: (options: string | string[]) => void;
|
||||
newOptionPlaceholder: string;
|
||||
setNewOptionPlaceholder: (placeholder: string) => void;
|
||||
newOptionDefaultValue: string;
|
||||
@@ -54,8 +56,9 @@ export interface UseAttributeManagementReturn {
|
||||
setNewColumnName: (name: string) => void;
|
||||
newColumnKey: string;
|
||||
setNewColumnKey: (key: string) => void;
|
||||
newColumnType: 'text' | 'number';
|
||||
setNewColumnType: (type: 'text' | 'number') => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
newColumnType: string;
|
||||
setNewColumnType: (type: string) => void;
|
||||
newColumnRequired: boolean;
|
||||
setNewColumnRequired: (required: boolean) => void;
|
||||
|
||||
@@ -114,7 +117,7 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
|
||||
const [newOptionValue, setNewOptionValue] = useState('');
|
||||
const [newOptionLabel, setNewOptionLabel] = useState('');
|
||||
const [newOptionColumnValues, setNewOptionColumnValues] = useState<Record<string, string>>({});
|
||||
const [newOptionInputType, setNewOptionInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
|
||||
const [newOptionInputType, setNewOptionInputType] = useState<string>('textbox');
|
||||
const [newOptionRequired, setNewOptionRequired] = useState(false);
|
||||
const [newOptionOptions, setNewOptionOptions] = useState('');
|
||||
const [newOptionPlaceholder, setNewOptionPlaceholder] = useState('');
|
||||
@@ -128,7 +131,7 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
|
||||
// 칼럼 폼 상태
|
||||
const [newColumnName, setNewColumnName] = useState('');
|
||||
const [newColumnKey, setNewColumnKey] = useState('');
|
||||
const [newColumnType, setNewColumnType] = useState<'text' | 'number'>('text');
|
||||
const [newColumnType, setNewColumnType] = useState<string>('text');
|
||||
const [newColumnRequired, setNewColumnRequired] = useState(false);
|
||||
|
||||
// 이전 옵션 값 추적용 ref (무한 루프 방지)
|
||||
@@ -314,6 +317,15 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
|
||||
setNewColumnRequired(false);
|
||||
};
|
||||
|
||||
// 유연한 타입 래퍼: string | string[] → string 변환
|
||||
const handleSetNewOptionOptions = (options: string | string[]) => {
|
||||
if (Array.isArray(options)) {
|
||||
setNewOptionOptions(options.join(', '));
|
||||
} else {
|
||||
setNewOptionOptions(options);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
// 속성 옵션 상태
|
||||
unitOptions,
|
||||
@@ -343,7 +355,7 @@ export function useAttributeManagement(): UseAttributeManagementReturn {
|
||||
newOptionRequired,
|
||||
setNewOptionRequired,
|
||||
newOptionOptions,
|
||||
setNewOptionOptions,
|
||||
setNewOptionOptions: handleSetNewOptionOptions,
|
||||
newOptionPlaceholder,
|
||||
setNewOptionPlaceholder,
|
||||
newOptionDefaultValue,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from 'sonner';
|
||||
import type { ItemPage, BOMItem } from '@/contexts/ItemMasterContext';
|
||||
import type { CustomTab, AttributeSubTab } from './useTabManagement';
|
||||
import type { MasterOption, OptionColumn } from '../types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// 타입 alias (기존 호환성)
|
||||
type UnitOption = MasterOption;
|
||||
@@ -64,6 +65,7 @@ export function useDeleteManagement({ itemPages }: UseDeleteManagementProps): Us
|
||||
await unlinkFieldFromSection(Number(sectionId), Number(fieldId));
|
||||
console.log('필드 연결 해제 완료:', fieldId);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('필드 연결 해제 실패:', error);
|
||||
toast.error('필드 연결 해제에 실패했습니다');
|
||||
}
|
||||
@@ -110,6 +112,7 @@ export function useDeleteManagement({ itemPages }: UseDeleteManagementProps): Us
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error('초기화 중 오류가 발생했습니다');
|
||||
console.error('Reset error:', error);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ import type { ItemPage, ItemField, ItemMasterField, FieldDisplayCondition } from
|
||||
import { type ConditionalFieldConfig } from '../components/ConditionalDisplayUI';
|
||||
import { fieldService } from '../services';
|
||||
import { ApiError } from '@/lib/api/error-handler';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// 필드 타입 정의 - field_type 캐스팅용
|
||||
type FieldTypeValue = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
|
||||
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'master' 모두 지원
|
||||
type FieldInputModeType = 'new' | 'existing' | 'custom' | 'master';
|
||||
|
||||
export interface UseFieldManagementReturn {
|
||||
// 다이얼로그 상태
|
||||
@@ -19,20 +26,22 @@ export interface UseFieldManagementReturn {
|
||||
setEditingFieldId: (id: number | null) => void;
|
||||
|
||||
// 입력 모드
|
||||
fieldInputMode: 'master' | 'custom';
|
||||
setFieldInputMode: (mode: 'master' | 'custom') => void;
|
||||
fieldInputMode: FieldInputModeType;
|
||||
setFieldInputMode: (mode: FieldInputModeType) => void;
|
||||
showMasterFieldList: boolean;
|
||||
setShowMasterFieldList: (show: boolean) => void;
|
||||
selectedMasterFieldId: string;
|
||||
setSelectedMasterFieldId: (id: string) => void;
|
||||
// string 또는 number | null 모두 지원
|
||||
selectedMasterFieldId: string | number | null;
|
||||
setSelectedMasterFieldId: (id: string | number | null) => void;
|
||||
|
||||
// 필드 폼 상태
|
||||
newFieldName: string;
|
||||
setNewFieldName: (name: string) => void;
|
||||
newFieldKey: string;
|
||||
setNewFieldKey: (key: string) => void;
|
||||
newFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
setNewFieldInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
newFieldInputType: string;
|
||||
setNewFieldInputType: (type: string) => void;
|
||||
newFieldRequired: boolean;
|
||||
setNewFieldRequired: (required: boolean) => void;
|
||||
newFieldOptions: string;
|
||||
@@ -90,14 +99,14 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
const [editingFieldId, setEditingFieldId] = useState<number | null>(null);
|
||||
|
||||
// 입력 모드
|
||||
const [fieldInputMode, setFieldInputMode] = useState<'master' | 'custom'>('custom');
|
||||
const [fieldInputMode, setFieldInputMode] = useState<FieldInputModeType>('custom');
|
||||
const [showMasterFieldList, setShowMasterFieldList] = useState(false);
|
||||
const [selectedMasterFieldId, setSelectedMasterFieldId] = useState('');
|
||||
const [selectedMasterFieldId, setSelectedMasterFieldId] = useState<string | number | null>('');
|
||||
|
||||
// 필드 폼 상태
|
||||
const [newFieldName, setNewFieldName] = useState('');
|
||||
const [newFieldKey, setNewFieldKey] = useState('');
|
||||
const [newFieldInputType, setNewFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
|
||||
const [newFieldInputType, setNewFieldInputType] = useState<string>('textbox');
|
||||
const [newFieldRequired, setNewFieldRequired] = useState(false);
|
||||
const [newFieldOptions, setNewFieldOptions] = useState('');
|
||||
const [newFieldDescription, setNewFieldDescription] = useState('');
|
||||
@@ -191,7 +200,7 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
master_field_id: masterFieldId,
|
||||
field_name: newFieldName,
|
||||
field_key: newFieldKey, // 2025-11-28: field_key 추가 (백엔드에서 {ID}_{입력값} 형태로 저장)
|
||||
field_type: newFieldInputType,
|
||||
field_type: newFieldInputType as FieldTypeValue,
|
||||
order_no: 0,
|
||||
is_required: newFieldRequired,
|
||||
placeholder: newFieldDescription || null,
|
||||
@@ -242,6 +251,7 @@ export function useFieldManagement(): UseFieldManagementReturn {
|
||||
|
||||
resetFieldForm();
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('필드 처리 실패:', error);
|
||||
|
||||
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useItemMaster } from '@/contexts/ItemMasterContext';
|
||||
import { getErrorMessage } from '@/lib/api/error-handler';
|
||||
import { toast } from 'sonner';
|
||||
import type { ItemPage } from '@/contexts/ItemMasterContext';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
export interface UseImportManagementReturn {
|
||||
// 섹션 Import 상태
|
||||
@@ -52,6 +53,7 @@ export function useImportManagement(): UseImportManagementReturn {
|
||||
toast.success('섹션을 불러왔습니다.');
|
||||
setSelectedImportSectionId(null);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 불러오기 실패:', error);
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
@@ -73,6 +75,7 @@ export function useImportManagement(): UseImportManagementReturn {
|
||||
setSelectedImportFieldId(null);
|
||||
setImportFieldTargetSectionId(null);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('필드 불러오기 실패:', error);
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
@@ -84,6 +87,7 @@ export function useImportManagement(): UseImportManagementReturn {
|
||||
await cloneSection(sectionId);
|
||||
toast.success('섹션이 복제되었습니다.');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 복제 실패:', error);
|
||||
toast.error(getErrorMessage(error));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { useErrorAlert } from '../contexts';
|
||||
import type { ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
import { masterFieldService } from '../services';
|
||||
import { ApiError } from '@/lib/api/error-handler';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// 필드 타입 정의 - field_type 캐스팅용
|
||||
type FieldTypeValue = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
|
||||
/**
|
||||
* @deprecated 2025-11-27: item_fields로 통합됨.
|
||||
@@ -26,8 +30,9 @@ export interface UseMasterFieldManagementReturn {
|
||||
setNewMasterFieldName: (name: string) => void;
|
||||
newMasterFieldKey: string;
|
||||
setNewMasterFieldKey: (key: string) => void;
|
||||
newMasterFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
setNewMasterFieldInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
newMasterFieldInputType: string;
|
||||
setNewMasterFieldInputType: (type: string) => void;
|
||||
newMasterFieldRequired: boolean;
|
||||
setNewMasterFieldRequired: (required: boolean) => void;
|
||||
newMasterFieldCategory: string;
|
||||
@@ -36,8 +41,9 @@ export interface UseMasterFieldManagementReturn {
|
||||
setNewMasterFieldDescription: (desc: string) => void;
|
||||
newMasterFieldOptions: string;
|
||||
setNewMasterFieldOptions: (options: string) => void;
|
||||
newMasterFieldAttributeType: 'custom' | 'unit' | 'material' | 'surface';
|
||||
setNewMasterFieldAttributeType: (type: 'custom' | 'unit' | 'material' | 'surface') => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
newMasterFieldAttributeType: string;
|
||||
setNewMasterFieldAttributeType: (type: string) => void;
|
||||
newMasterFieldMultiColumn: boolean;
|
||||
setNewMasterFieldMultiColumn: (multi: boolean) => void;
|
||||
newMasterFieldColumnCount: number;
|
||||
@@ -71,12 +77,12 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
// 폼 상태
|
||||
const [newMasterFieldName, setNewMasterFieldName] = useState('');
|
||||
const [newMasterFieldKey, setNewMasterFieldKey] = useState('');
|
||||
const [newMasterFieldInputType, setNewMasterFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
|
||||
const [newMasterFieldInputType, setNewMasterFieldInputType] = useState<string>('textbox');
|
||||
const [newMasterFieldRequired, setNewMasterFieldRequired] = useState(false);
|
||||
const [newMasterFieldCategory, setNewMasterFieldCategory] = useState('공통');
|
||||
const [newMasterFieldDescription, setNewMasterFieldDescription] = useState('');
|
||||
const [newMasterFieldOptions, setNewMasterFieldOptions] = useState('');
|
||||
const [newMasterFieldAttributeType, setNewMasterFieldAttributeType] = useState<'custom' | 'unit' | 'material' | 'surface'>('custom');
|
||||
const [newMasterFieldAttributeType, setNewMasterFieldAttributeType] = useState<string>('custom');
|
||||
const [newMasterFieldMultiColumn, setNewMasterFieldMultiColumn] = useState(false);
|
||||
const [newMasterFieldColumnCount, setNewMasterFieldColumnCount] = useState(2);
|
||||
const [newMasterFieldColumnNames, setNewMasterFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
|
||||
@@ -93,7 +99,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
const newMasterFieldData: Omit<ItemMasterField, 'id' | 'tenant_id' | 'created_by' | 'updated_by' | 'created_at' | 'updated_at'> = {
|
||||
field_name: newMasterFieldName,
|
||||
field_key: newMasterFieldKey, // 2025-11-28: field_key 추가
|
||||
field_type: newMasterFieldInputType,
|
||||
field_type: newMasterFieldInputType as FieldTypeValue,
|
||||
category: newMasterFieldCategory || null,
|
||||
description: newMasterFieldDescription || null,
|
||||
is_common: false,
|
||||
@@ -116,6 +122,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
resetMasterFieldForm();
|
||||
toast.success('항목이 추가되었습니다');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('항목 추가 실패:', error);
|
||||
|
||||
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어) → AlertDialog로 표시
|
||||
@@ -170,7 +177,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
const updateData: Partial<ItemMasterField> = {
|
||||
field_name: newMasterFieldName,
|
||||
field_key: newMasterFieldKey, // 2025-11-28: field_key 추가
|
||||
field_type: newMasterFieldInputType,
|
||||
field_type: newMasterFieldInputType as FieldTypeValue,
|
||||
category: newMasterFieldCategory || null,
|
||||
description: newMasterFieldDescription || null,
|
||||
options: newMasterFieldInputType === 'dropdown' && newMasterFieldOptions.trim()
|
||||
@@ -190,6 +197,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
resetMasterFieldForm();
|
||||
toast.success('항목이 수정되었습니다');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('항목 수정 실패:', error);
|
||||
|
||||
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등) → AlertDialog로 표시
|
||||
@@ -218,6 +226,7 @@ export function useMasterFieldManagement(): UseMasterFieldManagementReturn {
|
||||
await deleteItemMasterField(id);
|
||||
toast.success('항목이 삭제되었습니다');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('항목 삭제 실패:', error);
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useCallback } from 'react';
|
||||
import { useItemMaster } from '@/contexts/ItemMasterContext';
|
||||
import { toast } from 'sonner';
|
||||
import type { ItemPage } from '@/contexts/ItemMasterContext';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
export interface UseReorderManagementReturn {
|
||||
moveSection: (selectedPage: ItemPage | null, dragIndex: number, hoverIndex: number) => Promise<void>;
|
||||
@@ -34,6 +35,7 @@ export function useReorderManagement(): UseReorderManagementReturn {
|
||||
await reorderSections(selectedPage.id, sectionIds);
|
||||
toast.success('섹션 순서가 변경되었습니다');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 순서 변경 실패:', error);
|
||||
toast.error('섹션 순서 변경에 실패했습니다');
|
||||
}
|
||||
@@ -73,6 +75,7 @@ export function useReorderManagement(): UseReorderManagementReturn {
|
||||
await reorderFields(sectionId, newFieldIds);
|
||||
toast.success('항목 순서가 변경되었습니다');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
toast.error('항목 순서 변경에 실패했습니다');
|
||||
}
|
||||
}, [reorderFields]);
|
||||
|
||||
@@ -5,6 +5,10 @@ import { toast } from 'sonner';
|
||||
import { useItemMaster } from '@/contexts/ItemMasterContext';
|
||||
import type { ItemPage, ItemSection, SectionTemplate } from '@/contexts/ItemMasterContext';
|
||||
import { sectionService } from '../services';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// 입력 모드 타입: 'new'/'existing' 또는 'custom'/'template' 모두 지원
|
||||
type SectionInputModeType = 'new' | 'existing' | 'custom' | 'template';
|
||||
|
||||
export interface UseSectionManagementReturn {
|
||||
// 상태
|
||||
@@ -20,8 +24,8 @@ export interface UseSectionManagementReturn {
|
||||
setNewSectionDescription: (desc: string) => void;
|
||||
newSectionType: 'fields' | 'bom';
|
||||
setNewSectionType: (type: 'fields' | 'bom') => void;
|
||||
sectionInputMode: 'custom' | 'template';
|
||||
setSectionInputMode: (mode: 'custom' | 'template') => void;
|
||||
sectionInputMode: SectionInputModeType;
|
||||
setSectionInputMode: (mode: SectionInputModeType) => void;
|
||||
selectedSectionTemplateId: number | null;
|
||||
setSelectedSectionTemplateId: (id: number | null) => void;
|
||||
expandedSections: Record<string, boolean>;
|
||||
@@ -55,7 +59,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
const [newSectionTitle, setNewSectionTitle] = useState('');
|
||||
const [newSectionDescription, setNewSectionDescription] = useState('');
|
||||
const [newSectionType, setNewSectionType] = useState<'fields' | 'bom'>('fields');
|
||||
const [sectionInputMode, setSectionInputMode] = useState<'custom' | 'template'>('custom');
|
||||
const [sectionInputMode, setSectionInputMode] = useState<SectionInputModeType>('custom');
|
||||
const [selectedSectionTemplateId, setSelectedSectionTemplateId] = useState<number | null>(null);
|
||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -102,6 +106,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
resetSectionForm();
|
||||
toast.success(`${newSectionType === 'bom' ? 'BOM' : '일반'} 섹션이 추가되었습니다!`);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 추가 실패:', error);
|
||||
toast.error('섹션 추가에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
@@ -135,6 +140,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
resetSectionForm();
|
||||
toast.success(`"${template.template_name}" 섹션이 페이지에 연결되었습니다!`);
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 연결 실패:', error);
|
||||
toast.error('섹션 연결에 실패했습니다. 다시 시도해주세요.');
|
||||
}
|
||||
@@ -159,6 +165,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
setEditingSectionTitle('');
|
||||
toast.success('섹션 제목이 수정되었습니다!');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 제목 수정 실패:', error);
|
||||
toast.error('섹션 제목 수정에 실패했습니다.');
|
||||
}
|
||||
@@ -172,6 +179,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
console.log('섹션 연결 해제 완료:', { pageId, sectionId });
|
||||
toast.success('섹션 연결이 해제되었습니다');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 연결 해제 실패:', error);
|
||||
toast.error('섹션 연결 해제에 실패했습니다.');
|
||||
}
|
||||
@@ -191,6 +199,7 @@ export function useSectionManagement(): UseSectionManagementReturn {
|
||||
});
|
||||
toast.success('섹션이 삭제되었습니다!');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 삭제 실패:', error);
|
||||
toast.error('섹션 삭제에 실패했습니다.');
|
||||
}
|
||||
|
||||
@@ -7,6 +7,10 @@ import { useErrorAlert } from '../contexts';
|
||||
import type { ItemPage, SectionTemplate, TemplateField, BOMItem, ItemMasterField } from '@/contexts/ItemMasterContext';
|
||||
import { templateService } from '../services';
|
||||
import { ApiError } from '@/lib/api/error-handler';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// 필드 타입 정의 - field_type 캐스팅용
|
||||
type FieldTypeValue = 'textbox' | 'number' | 'dropdown' | 'checkbox' | 'date' | 'textarea';
|
||||
|
||||
export interface UseTemplateManagementReturn {
|
||||
// 섹션 템플릿 다이얼로그 상태
|
||||
@@ -44,8 +48,9 @@ export interface UseTemplateManagementReturn {
|
||||
setTemplateFieldName: (name: string) => void;
|
||||
templateFieldKey: string;
|
||||
setTemplateFieldKey: (key: string) => void;
|
||||
templateFieldInputType: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
setTemplateFieldInputType: (type: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea') => void;
|
||||
// string 타입으로 유연하게 처리
|
||||
templateFieldInputType: string;
|
||||
setTemplateFieldInputType: (type: string) => void;
|
||||
templateFieldRequired: boolean;
|
||||
setTemplateFieldRequired: (required: boolean) => void;
|
||||
templateFieldOptions: string;
|
||||
@@ -59,9 +64,9 @@ export interface UseTemplateManagementReturn {
|
||||
templateFieldColumnNames: string[];
|
||||
setTemplateFieldColumnNames: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
|
||||
// 템플릿 필드 마스터 항목 관련
|
||||
templateFieldInputMode: 'custom' | 'master';
|
||||
setTemplateFieldInputMode: (mode: 'custom' | 'master') => void;
|
||||
// 템플릿 필드 마스터 항목 관련 - 유연한 타입 지원
|
||||
templateFieldInputMode: 'custom' | 'master' | 'new' | 'existing';
|
||||
setTemplateFieldInputMode: (mode: 'custom' | 'master' | 'new' | 'existing') => void;
|
||||
templateFieldShowMasterFieldList: boolean;
|
||||
setTemplateFieldShowMasterFieldList: (show: boolean) => void;
|
||||
templateFieldSelectedMasterFieldId: string;
|
||||
@@ -139,7 +144,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
// 템플릿 필드 폼 상태
|
||||
const [templateFieldName, setTemplateFieldName] = useState('');
|
||||
const [templateFieldKey, setTemplateFieldKey] = useState('');
|
||||
const [templateFieldInputType, setTemplateFieldInputType] = useState<'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea'>('textbox');
|
||||
const [templateFieldInputType, setTemplateFieldInputType] = useState<string>('textbox');
|
||||
const [templateFieldRequired, setTemplateFieldRequired] = useState(false);
|
||||
const [templateFieldOptions, setTemplateFieldOptions] = useState('');
|
||||
const [templateFieldDescription, setTemplateFieldDescription] = useState('');
|
||||
@@ -147,8 +152,8 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
const [templateFieldColumnCount, setTemplateFieldColumnCount] = useState(2);
|
||||
const [templateFieldColumnNames, setTemplateFieldColumnNames] = useState<string[]>(['컬럼1', '컬럼2']);
|
||||
|
||||
// 템플릿 필드 마스터 항목 관련
|
||||
const [templateFieldInputMode, setTemplateFieldInputMode] = useState<'custom' | 'master'>('custom');
|
||||
// 템플릿 필드 마스터 항목 관련 - 'new'/'existing' 호환을 위해 유연하게 처리
|
||||
const [templateFieldInputMode, setTemplateFieldInputMode] = useState<'custom' | 'master' | 'new' | 'existing'>('custom');
|
||||
const [templateFieldShowMasterFieldList, setTemplateFieldShowMasterFieldList] = useState(false);
|
||||
const [templateFieldSelectedMasterFieldId, setTemplateFieldSelectedMasterFieldId] = useState('');
|
||||
|
||||
@@ -176,6 +181,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션이 추가되었습니다!');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 추가 실패:', error);
|
||||
toast.error('섹션 추가에 실패했습니다.');
|
||||
}
|
||||
@@ -212,6 +218,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
resetSectionTemplateForm();
|
||||
toast.success('섹션이 수정되었습니다!');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 수정 실패:', error);
|
||||
toast.error('섹션 수정에 실패했습니다.');
|
||||
}
|
||||
@@ -225,6 +232,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
await deleteSection(id);
|
||||
toast.success('섹션이 삭제되었습니다!');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('섹션 삭제 실패:', error);
|
||||
toast.error('섹션 삭제에 실패했습니다.');
|
||||
}
|
||||
@@ -282,7 +290,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
const updateData = {
|
||||
field_name: templateFieldName,
|
||||
field_key: templateFieldKey, // 2025-11-28: field_key 추가
|
||||
field_type: templateFieldInputType,
|
||||
field_type: templateFieldInputType as FieldTypeValue,
|
||||
is_required: templateFieldRequired,
|
||||
placeholder: templateFieldDescription || null,
|
||||
options: templateFieldInputType === 'dropdown' && templateFieldOptions.trim()
|
||||
@@ -326,7 +334,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
master_field_id: null,
|
||||
field_name: templateFieldName,
|
||||
field_key: templateFieldKey,
|
||||
field_type: templateFieldInputType,
|
||||
field_type: templateFieldInputType as FieldTypeValue,
|
||||
order_no: 0,
|
||||
is_required: templateFieldRequired,
|
||||
placeholder: templateFieldDescription || null,
|
||||
@@ -352,6 +360,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
|
||||
resetTemplateFieldForm();
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('항목 처리 실패:', error);
|
||||
|
||||
// 422 ValidationException 상세 메시지 처리 (field_key 중복/예약어, field_name 중복 등) → AlertDialog로 표시
|
||||
@@ -402,6 +411,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
await unlinkFieldFromSection(templateId, Number(fieldId));
|
||||
toast.success('항목 연결이 해제되었습니다');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('항목 연결 해제 실패:', error);
|
||||
toast.error('항목 연결 해제에 실패했습니다.');
|
||||
}
|
||||
@@ -415,6 +425,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
await addBOMItem(templateId, item);
|
||||
// toast는 BOMManagementSection 컴포넌트에서 처리
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('BOM 항목 추가 실패:', error);
|
||||
toast.error('BOM 항목 추가에 실패했습니다');
|
||||
}
|
||||
@@ -427,6 +438,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
await updateBOMItem(itemId, item);
|
||||
// toast는 BOMManagementSection 컴포넌트에서 처리
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('BOM 항목 수정 실패:', error);
|
||||
toast.error('BOM 항목 수정에 실패했습니다');
|
||||
}
|
||||
@@ -439,6 +451,7 @@ export function useTemplateManagement(): UseTemplateManagementReturn {
|
||||
await deleteBOMItem(itemId);
|
||||
// toast는 BOMManagementSection 컴포넌트에서 처리
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('BOM 항목 삭제 실패:', error);
|
||||
toast.error('BOM 항목 삭제에 실패했습니다');
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Plus, Edit, Trash2, Link, Copy, Download } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import { DraggableSection, DraggableField } from '../../components';
|
||||
import { BOMManagementSection } from '@/components/items/BOMManagementSection';
|
||||
|
||||
@@ -385,6 +386,7 @@ export function HierarchyTab({
|
||||
console.log('[HierarchyTab] BOM 추가 성공');
|
||||
toast.success('BOM 항목이 추가되었습니다');
|
||||
} catch (error) {
|
||||
if (isNextRedirectError(error)) throw error;
|
||||
console.error('[HierarchyTab] BOM 추가 실패:', error);
|
||||
toast.error('BOM 항목 추가에 실패했습니다. 백엔드 API를 확인하세요.');
|
||||
}
|
||||
|
||||
@@ -7,12 +7,12 @@
|
||||
* - ItemFieldProperty, SectionTemplate
|
||||
*/
|
||||
|
||||
// 옵션 칼럼 타입
|
||||
// 옵션 칼럼 타입 (string 타입으로 유연하게 처리)
|
||||
export interface OptionColumn {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
type: 'text' | 'number';
|
||||
type: string; // 'text' | 'number' 등 다양한 타입 지원
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
@@ -22,8 +22,8 @@ export interface MasterOption {
|
||||
value: string;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
// 입력 방식 및 속성
|
||||
inputType?: 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea';
|
||||
// 입력 방식 및 속성 (string 타입으로 유연하게 처리)
|
||||
inputType?: string; // 'textbox' | 'dropdown' | 'checkbox' | 'number' | 'date' | 'textarea' 등
|
||||
required?: boolean;
|
||||
options?: string[]; // dropdown일 경우 선택 옵션
|
||||
defaultValue?: string | number | boolean;
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ClipboardCheck, Calendar, Loader2 } from 'lucide-react';
|
||||
import { ClipboardCheck, Calendar } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -27,6 +28,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { getReceivings } from './actions';
|
||||
import type { InspectionCheckItem, ReceivingItem } from './types';
|
||||
import { SuccessDialog } from './SuccessDialog';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// LOT 번호 생성 함수 (YYMMDD-NN 형식)
|
||||
function generateLotNo(): string {
|
||||
@@ -96,6 +98,7 @@ export function InspectionCreate({ id }: Props) {
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[InspectionCreate] loadTargets error:', err);
|
||||
} finally {
|
||||
setIsLoadingTargets(false);
|
||||
@@ -206,9 +209,7 @@ export function InspectionCreate({ id }: Props) {
|
||||
<Label className="text-sm font-medium">검사 대상 선택</Label>
|
||||
<div className="space-y-2 border rounded-lg p-2 bg-white min-h-[200px]">
|
||||
{isLoadingTargets ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
<ContentLoadingSpinner text="검사 대상을 불러오는 중..." />
|
||||
) : inspectionTargets.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
검사 대기 중인 입고 건이 없습니다.
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, FileText, List, ClipboardCheck, Download, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { Package, FileText, List, ClipboardCheck, Download, AlertCircle } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -22,6 +23,7 @@ import type { ReceivingDetail as ReceivingDetailType, ReceivingProcessFormData }
|
||||
import { ReceivingProcessDialog } from './ReceivingProcessDialog';
|
||||
import { ReceivingReceiptDialog } from './ReceivingReceiptDialog';
|
||||
import { SuccessDialog } from './SuccessDialog';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
@@ -56,6 +58,7 @@ export function ReceivingDetail({ id }: Props) {
|
||||
setError(result.error || '입고 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[ReceivingDetail] loadData error:', err);
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
@@ -100,6 +103,7 @@ export function ReceivingDetail({ id }: Props) {
|
||||
alert(result.error || '입고처리에 실패했습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[ReceivingDetail] handleReceivingComplete error:', err);
|
||||
alert('입고처리 중 오류가 발생했습니다.');
|
||||
}
|
||||
@@ -115,9 +119,7 @@ export function ReceivingDetail({ id }: Props) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<ContentLoadingSpinner text="입고 정보를 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
ClipboardCheck,
|
||||
Calendar,
|
||||
Eye,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard';
|
||||
import { getReceivings, getReceivingStats } from './actions';
|
||||
import { RECEIVING_STATUS_LABELS, RECEIVING_STATUS_STYLES } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
import type { ReceivingItem, ReceivingStats } from './types';
|
||||
|
||||
// 페이지당 항목 수
|
||||
@@ -87,6 +88,7 @@ export function ReceivingList() {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[ReceivingList] loadData error:', err);
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
@@ -317,11 +319,7 @@ export function ReceivingList() {
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading && items.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
return <ContentLoadingSpinner text="입고 목록을 불러오는 중..." />;
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, AlertCircle, Loader2, List } from 'lucide-react';
|
||||
import { Package, AlertCircle, List } from 'lucide-react';
|
||||
import { ContentLoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
LOT_STATUS_LABELS,
|
||||
} from './types';
|
||||
import type { StockDetail, LotDetail } from './types';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
interface StockStatusDetailProps {
|
||||
id: string;
|
||||
@@ -55,6 +57,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
setError(result.error || '재고 정보를 찾을 수 없습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isNextRedirectError(err)) throw err;
|
||||
console.error('[StockStatusDetail] loadData error:', err);
|
||||
setError('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
@@ -90,9 +93,7 @@ export function StockStatusDetail({ id }: StockStatusDetailProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
||||
</div>
|
||||
<ContentLoadingSpinner text="재고 정보를 불러오는 중..." />
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user