diff --git a/claudedocs/[PLAN] mobile-overflow-testing.md b/claudedocs/[PLAN] mobile-overflow-testing.md new file mode 100644 index 00000000..6c3c7e64 --- /dev/null +++ b/claudedocs/[PLAN] mobile-overflow-testing.md @@ -0,0 +1,386 @@ +# 모바일 화면 오버플로우 테스트 계획서 + +> 작성일: 2026-01-09 +> 대상 기기: Galaxy Z Fold (접힌 상태) +> 목표: 모든 페이지에서 텍스트/요소 오버플로우 검출 및 수정 + +--- + +## 1. 개요 + +### 1.1 목적 +Galaxy Fold 접힌 상태(344px)에서 UI 요소가 컨테이너를 벗어나거나 텍스트가 잘리는 문제를 사전에 발견하고 수정 + +### 1.2 대상 뷰포트 + +| 기기 | 너비 | 높이 | 비고 | +|------|------|------|------| +| Galaxy Z Fold 5 (접힌) | **344px** | 882px | 주요 테스트 대상 | +| Galaxy Z Fold 5 (펼친) | 1812px | 882px | 참고용 | +| iPhone SE | 375px | 667px | 비교 테스트 | + +### 1.3 테스트 범위 + +**총 페이지 수: 185개** + +| 카테고리 | 페이지 수 | 우선순위 | +|----------|----------|----------| +| construction (시공) | 40 | 🔴 높음 | +| accounting (회계) | 26 | 🔴 높음 | +| sales (영업) | 18 | 🔴 높음 | +| settings (설정) | 17 | 🟡 중간 | +| hr (인사) | 14 | 🟡 중간 | +| production (생산) | 10 | 🟡 중간 | +| quality (품질) | 4 | 🟢 낮음 | +| reports (리포트) | 2 | 🟢 낮음 | +| dashboard | 1 | 🔴 높음 | +| 기타 | ~50 | 🟡 중간 | + +--- + +## 2. 테스트 방법 + +### 2.1 방법 A: Playwright 자동화 (권장) + +**장점** +- 전체 페이지 일괄 스크린샷 +- 반복 테스트 용이 +- 수정 후 비교 테스트 가능 + +**단점** +- 초기 세팅 필요 +- 로그인/인증 처리 필요 + +**구현 방식** +```typescript +// playwright-mobile-test.ts +import { chromium } from 'playwright'; + +const VIEWPORT = { width: 344, height: 882 }; +const BASE_URL = 'http://localhost:3000/ko'; + +const pages = [ + '/dashboard', + '/sales/client-management-sales-admin', + '/accounting/sales', + // ... 전체 페이지 목록 +]; + +async function captureScreenshots() { + const browser = await chromium.launch(); + const context = await browser.newContext({ + viewport: VIEWPORT, + // 로그인 쿠키 설정 + }); + + for (const page of pages) { + const p = await context.newPage(); + await p.goto(`${BASE_URL}${page}`); + await p.screenshot({ + path: `screenshots/fold/${page.replace(/\//g, '-')}.png`, + fullPage: true + }); + } +} +``` + +**결과물** +``` +screenshots/fold/ +├── dashboard.png +├── sales-client-management-sales-admin.png +├── accounting-sales.png +└── ... (185개) +``` + +--- + +### 2.2 방법 B: Chrome DevTools 수동 검수 + +**장점** +- 즉시 시작 가능 +- 실시간 CSS 수정 테스트 가능 +- 인터랙션 확인 가능 + +**단점** +- 시간 소요 (페이지당 1-2분) +- 반복 테스트 불편 + +**설정 방법** +1. Chrome DevTools (F12) 열기 +2. Device Toolbar (Ctrl+Shift+M) 활성화 +3. 기기 목록 → Edit → Add custom device +4. 이름: `Galaxy Z Fold 5 (Folded)` +5. 너비: `344`, 높이: `882` +6. Device pixel ratio: `3` +7. User agent: Mobile + +**체크리스트** +```markdown +## 페이지: [페이지명] + +### 레이아웃 +- [ ] 헤더 정상 표시 +- [ ] 사이드바 접힘/메뉴 버튼 표시 +- [ ] 메인 컨텐츠 영역 정상 + +### 텍스트 +- [ ] 제목 텍스트 오버플로우 없음 +- [ ] 버튼 텍스트 잘림 없음 +- [ ] 테이블 헤더 가독성 확인 + +### 테이블/리스트 +- [ ] 가로 스크롤 정상 동작 +- [ ] 컬럼 최소 너비 확보 +- [ ] 체크박스/액션 버튼 접근 가능 + +### 폼 +- [ ] 입력 필드 너비 적절 +- [ ] 라벨 텍스트 가독성 +- [ ] 버튼 터치 영역 충분 (최소 44px) + +### 모달/팝업 +- [ ] 화면 내 표시 +- [ ] 닫기 버튼 접근 가능 +- [ ] 스크롤 정상 동작 +``` + +--- + +### 2.3 방법 C: 혼합 방식 (권장) + +1. **1단계**: Playwright로 전체 페이지 스크린샷 캡처 +2. **2단계**: 스크린샷에서 문제 있어 보이는 페이지 목록 작성 +3. **3단계**: 문제 페이지만 DevTools로 상세 검수 +4. **4단계**: 수정 후 Playwright로 재검증 + +--- + +## 3. 예상 문제 패턴 + +### 3.1 높은 위험도 🔴 + +| 패턴 | 예시 | 해결 방법 | +|------|------|----------| +| 고정 너비 테이블 | `min-w-[800px]` | 가로 스크롤 또는 반응형 | +| 긴 텍스트 nowrap | `whitespace-nowrap` | `truncate` 또는 줄바꿈 | +| 고정 px 버튼 그룹 | `w-[400px]` | `w-full` 또는 flex-wrap | +| 큰 모달 | `max-w-4xl` | `max-w-[calc(100vw-2rem)]` | + +### 3.2 중간 위험도 🟡 + +| 패턴 | 예시 | 해결 방법 | +|------|------|----------| +| Flex 오버플로우 | `flex gap-4` 자식 | `min-w-0` 추가 | +| Grid 고정 컬럼 | `grid-cols-4` | `grid-cols-1 md:grid-cols-4` | +| 이미지 고정 크기 | `w-[200px]` | `max-w-full` | + +### 3.3 낮은 위험도 🟢 + +| 패턴 | 예시 | 해결 방법 | +|------|------|----------| +| 패딩 과다 | `p-8` | `p-4 md:p-8` | +| 폰트 크기 | `text-xl` | `text-lg md:text-xl` | + +--- + +## 4. 수정 가이드라인 + +### 4.1 테이블 반응형 처리 + +```tsx +// Before +
+ + +// After +
+
+``` + +### 4.2 텍스트 오버플로우 처리 + +```tsx +// Before +{longText} + +// After +{longText} +``` + +### 4.3 버튼 그룹 반응형 + +```tsx +// Before +
+ + + +
+ +// After +
+ + + +
+``` + +### 4.4 모달 반응형 + +```tsx +// Before + + +// After + +``` + +--- + +## 5. 실행 계획 + +### 5.1 Phase 1: 환경 준비 (30분) + +- [ ] Playwright 스크립트 작성 +- [ ] 로그인 토큰/쿠키 설정 +- [ ] 테스트 페이지 URL 목록 정리 +- [ ] 스크린샷 저장 폴더 생성 + +### 5.2 Phase 2: 스크린샷 캡처 (1-2시간) + +- [ ] Playwright 스크립트 실행 +- [ ] 185개 페이지 스크린샷 캡처 +- [ ] 캡처 실패 페이지 확인 및 재시도 + +### 5.3 Phase 3: 문제 페이지 분류 (1시간) + +스크린샷 검토 후 분류: + +| 상태 | 설명 | 액션 | +|------|------|------| +| ✅ OK | 문제 없음 | 스킵 | +| ⚠️ Minor | 경미한 문제 | 백로그 | +| 🔴 Critical | 사용 불가 수준 | 즉시 수정 | + +### 5.4 Phase 4: 수정 작업 (문제 수에 따라) + +- [ ] Critical 문제 우선 수정 +- [ ] 수정 후 해당 페이지 재캡처 +- [ ] Before/After 비교 확인 + +### 5.5 Phase 5: 검증 (30분) + +- [ ] 전체 재캡처 +- [ ] 수정 결과 확인 +- [ ] 결과 보고서 작성 + +--- + +## 6. 결과물 + +### 6.1 스크린샷 폴더 구조 + +``` +screenshots/ +├── fold-344px/ +│ ├── dashboard.png +│ ├── sales/ +│ │ ├── client-management.png +│ │ └── quote-management.png +│ └── accounting/ +│ └── ... +├── issues/ +│ ├── critical/ +│ └── minor/ +└── fixed/ + └── before-after/ +``` + +### 6.2 이슈 리포트 + +```markdown +## 오버플로우 이슈 리포트 + +### Critical Issues (즉시 수정 필요) + +| # | 페이지 | 문제 | 스크린샷 | +|---|--------|------|----------| +| 1 | /sales/quote | 테이블 헤더 잘림 | [링크] | +| 2 | /accounting/daily-report | 차트 오버플로우 | [링크] | + +### Minor Issues (백로그) + +| # | 페이지 | 문제 | 스크린샷 | +|---|--------|------|----------| +| 1 | /settings/accounts | 버튼 그룹 좁음 | [링크] | +``` + +--- + +## 7. 예상 소요 시간 + +| 단계 | 예상 시간 | 비고 | +|------|----------|------| +| 환경 준비 | 30분 | Playwright 세팅 | +| 스크린샷 캡처 | 1-2시간 | 185페이지, 자동화 | +| 문제 분류 | 1시간 | 수동 검토 | +| 수정 작업 | 2-8시간 | 문제 수에 따라 | +| 검증 | 30분 | 재캡처 | +| **총합** | **5-12시간** | | + +--- + +## 8. 의사결정 포인트 + +### Q1: 자동화 vs 수동? +- **권장**: 혼합 방식 (자동 캡처 → 수동 분류 → 수정) + +### Q2: 전체 vs 우선순위별? +- **권장**: 전체 캡처 후, Critical만 우선 수정 + +### Q3: 지금 vs 나중에? +- 현재 수정 비용 < 나중 수정 비용 +- 가능하면 빠른 시일 내 진행 권장 + +--- + +## 9. 시작 전 필요한 것 + +1. **로컬 개발 서버** 실행 상태 +2. **테스트 계정** 로그인 정보 +3. **Node.js + Playwright** 설치 +4. **약 2-3시간** 집중 시간 + +--- + +## 부록: 페이지 URL 목록 + +
+전체 페이지 목록 (185개) - 클릭하여 펼치기 + +### Dashboard +- `/dashboard` + +### Sales (18개) +- `/sales/client-management-sales-admin` +- `/sales/quote-management` +- `/sales/order-management` +- ... (상세 목록 필요시 추가) + +### Accounting (26개) +- `/accounting/sales` +- `/accounting/vendors` +- `/accounting/bills` +- ... (상세 목록 필요시 추가) + +### Construction (40개) +- `/construction/sites` +- `/construction/work-logs` +- ... (상세 목록 필요시 추가) + +
+ +--- + +> **다음 단계**: 이 계획서 검토 후, 진행 방식 결정하면 Playwright 스크립트 작성 시작 \ No newline at end of file diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index 19a871b5..118e365d 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -10,6 +10,8 @@ import { } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; import { TableRow, TableCell } from '@/components/ui/table'; import { Dialog, @@ -49,6 +51,7 @@ import type { import { SORT_OPTIONS, ACCOUNT_SUBJECT_OPTIONS, + USAGE_TYPE_OPTIONS, } from './types'; import { getCardTransactionList, getCardTransactionSummary, bulkUpdateAccountCode } from './actions'; @@ -90,6 +93,15 @@ export function CardTransactionInquiry({ // 선택 필요 알림 다이얼로그 const [showSelectWarningDialog, setShowSelectWarningDialog] = useState(false); + // 상세 모달 상태 + const [showDetailModal, setShowDetailModal] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [detailFormData, setDetailFormData] = useState({ + memo: '', + usageType: 'unset', + }); + const [isDetailSaving, setIsDetailSaving] = useState(false); + // 날짜 범위 상태 const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); @@ -152,6 +164,40 @@ export function CardTransactionInquiry({ loadData(); }, [loadData]); + // ===== 상세 모달 핸들러 ===== + const handleRowClick = useCallback((item: CardTransaction) => { + setSelectedItem(item); + setDetailFormData({ + memo: item.memo || '', + usageType: item.usageType || 'unset', + }); + setShowDetailModal(true); + }, []); + + const handleDetailSave = useCallback(async () => { + if (!selectedItem) return; + + setIsDetailSaving(true); + try { + // TODO: API 호출로 상세 정보 저장 + // const result = await updateCardTransaction(selectedItem.id, detailFormData); + + // 임시: 로컬 데이터 업데이트 + setData(prev => prev.map(item => + item.id === selectedItem.id + ? { ...item, memo: detailFormData.memo, usageType: detailFormData.usageType } + : item + )); + + setShowDetailModal(false); + setSelectedItem(null); + } catch (error) { + console.error('[CardTransactionInquiry] handleDetailSave error:', error); + } finally { + setIsDetailSaving(false); + } + }, [selectedItem, detailFormData]); + // ===== 체크박스 핸들러 ===== const toggleSelection = useCallback((id: string) => { setSelectedItems(prev => { @@ -269,6 +315,11 @@ export function CardTransactionInquiry({ ]; }, [summary]); + // ===== 사용유형 라벨 변환 함수 ===== + const getUsageTypeLabel = useCallback((value: string) => { + return USAGE_TYPE_OPTIONS.find(opt => opt.value === value)?.label || '미설정'; + }, []); + // ===== 테이블 컬럼 (체크박스/번호 없음) ===== const tableColumns: TableColumn[] = useMemo(() => [ { key: 'card', label: '카드' }, @@ -277,6 +328,7 @@ export function CardTransactionInquiry({ { key: 'usedAt', label: '사용일시' }, { key: 'merchantName', label: '가맹점명' }, { key: 'amount', label: '사용금액', className: 'text-right' }, + { key: 'usageType', label: '사용유형' }, ], []); // ===== 테이블 행 렌더링 ===== @@ -286,7 +338,8 @@ export function CardTransactionInquiry({ return ( handleRowClick(item)} > {/* 체크박스 */} e.stopPropagation()}> @@ -306,9 +359,11 @@ export function CardTransactionInquiry({ {item.amount.toLocaleString()} + {/* 사용유형 */} + {getUsageTypeLabel(item.usageType)} ); - }, [selectedItems, toggleSelection]); + }, [selectedItems, toggleSelection, getUsageTypeLabel, handleRowClick]); // ===== 모바일 카드 렌더링 ===== const renderMobileCard = useCallback(( @@ -430,6 +485,7 @@ export function CardTransactionInquiry({ {totalAmount.toLocaleString()} + ); @@ -519,6 +575,94 @@ export function CardTransactionInquiry({ + + {/* 카드 내역 상세 모달 */} + + + + 카드 내역 상세 + + 카드 사용 상세 내역을 등록합니다 + + + + {selectedItem && ( +
+
+

기본 정보

+
+
+ +

{selectedItem.usedAt}

+
+
+ +

{selectedItem.card} ({selectedItem.cardName})

+
+
+ +

{selectedItem.user}

+
+
+ +

{selectedItem.amount.toLocaleString()}원

+
+
+ + setDetailFormData(prev => ({ ...prev, memo: e.target.value }))} + placeholder="적요" + className="mt-1" + /> +
+
+ +

{selectedItem.merchantName}

+
+
+ + +
+
+
+
+ )} + + + + +
+
); } \ No newline at end of file diff --git a/src/components/accounting/CardTransactionInquiry/types.ts b/src/components/accounting/CardTransactionInquiry/types.ts index 29b7198b..0e76fab5 100644 --- a/src/components/accounting/CardTransactionInquiry/types.ts +++ b/src/components/accounting/CardTransactionInquiry/types.ts @@ -9,6 +9,8 @@ export interface CardTransaction { usedAt: string; // 사용일시 merchantName: string; // 가맹점명 amount: number; // 사용금액 + memo?: string; // 적요 + usageType: string; // 사용유형 createdAt: string; updatedAt: string; } @@ -25,6 +27,28 @@ export const SORT_OPTIONS: { value: SortOption; label: string }[] = [ { value: 'amountLow', label: '금액낮은순' }, ]; +// ===== 사용유형 옵션 ===== +export const USAGE_TYPE_OPTIONS = [ + { value: 'unset', label: '미설정' }, + { value: 'welfare', label: '복리후생비' }, + { value: 'entertainment', label: '접대비' }, + { value: 'transportation', label: '여비교통비' }, + { value: 'vehicle', label: '차량유지비' }, + { value: 'supplies', label: '소모품비' }, + { value: 'delivery', label: '운반비' }, + { value: 'communication', label: '통신비' }, + { value: 'printing', label: '도서인쇄비' }, + { value: 'training', label: '교육훈련비' }, + { value: 'insurance', label: '보험료' }, + { value: 'advertising', label: '광고선전비' }, + { value: 'membership', label: '회비' }, + { value: 'commission', label: '지급수수료' }, + { value: 'taxesAndDues', label: '세금과공과' }, + { value: 'repair', label: '수선비' }, + { value: 'rent', label: '임차료' }, + { value: 'miscellaneous', label: '잡비' }, +]; + // ===== 계정과목명 옵션 (상단 셀렉트) ===== export const ACCOUNT_SUBJECT_OPTIONS = [ { value: 'unset', label: '미설정' }, diff --git a/src/components/business/CEODashboard/CEODashboard.tsx b/src/components/business/CEODashboard/CEODashboard.tsx index 550f217a..dad0cead 100644 --- a/src/components/business/CEODashboard/CEODashboard.tsx +++ b/src/components/business/CEODashboard/CEODashboard.tsx @@ -281,6 +281,7 @@ const mockData: CEODashboardData = { ], }, ], + detailButtonPath: '/accounting/receivables-status', }, debtCollection: { cards: [ @@ -955,12 +956,40 @@ export function CEODashboard() { cm4: { title: '대표자 종합소득세 예상 가중 상세', summaryCards: [ - { label: '합계', value: 3123000, unit: '원' }, - { label: '전월 대비', value: '+12.5%', isComparison: true, isPositive: false }, + { label: '대표자 종합세 예상 가중', value: 3123000, unit: '원' }, + { label: '추가 세금', value: '+12.5%', isComparison: true, isPositive: false }, { label: '가지급금', value: '4.5억원' }, - { label: '인정 이자', value: 6000000, unit: '원' }, + { label: '인정이자 4.6%', value: 6000000, unit: '원' }, ], - table: { + 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' }, @@ -989,16 +1018,450 @@ export function CEODashboard() { } }, []); - // 접대비 클릭 - const handleEntertainmentClick = useCallback(() => { - // TODO: 접대비 상세 팝업 열기 - console.log('접대비 클릭'); + // 접대비 현황 카드 클릭 (개별 카드 클릭 시 상세 모달) + const handleEntertainmentCardClick = useCallback((cardId: string) => { + // 접대비 상세 공통 모달 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 }, + ], + }, + }; + + const cardConfigs: Record = { + 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, + }; + + const config = cardConfigs[cardId]; + if (config) { + setDetailModalConfig(config); + setIsDetailModalOpen(true); + } }, []); - // 부가세 클릭 + // 복리후생비 현황 카드 클릭 (모든 카드가 동일한 상세 모달) + const handleWelfareCardClick = useCallback(() => { + // 계산 방식에 따른 조건부 calculationCards 생성 + const calculationType = dashboardSettings.welfare.calculationType; + 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 }, + ], + }; + + const config: DetailModalConfig = { + 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: '' }, + ], + }, + }; + + setDetailModalConfig(config); + setIsDetailModalOpen(true); + }, [dashboardSettings.welfare.calculationType]); + + // 부가세 클릭 (모든 카드가 동일한 상세 모달) const handleVatClick = useCallback(() => { - // TODO: 부가세 상세 팝업 열기 - console.log('부가세 클릭'); + const config: DetailModalConfig = { + 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', + }, + }; + + setDetailModalConfig(config); + setIsDetailModalOpen(true); }, []); // 캘린더 일정 클릭 (기존 일정 수정) @@ -1119,13 +1582,16 @@ export function CEODashboard() { {dashboardSettings.entertainment.enabled && ( )} {/* 복리후생비 현황 */} {dashboardSettings.welfare.enabled && ( - + )} {/* 미수금 현황 */} diff --git a/src/components/business/CEODashboard/modals/DetailModal.tsx b/src/components/business/CEODashboard/modals/DetailModal.tsx index beac2968..c6c20f75 100644 --- a/src/components/business/CEODashboard/modals/DetailModal.tsx +++ b/src/components/business/CEODashboard/modals/DetailModal.tsx @@ -38,6 +38,8 @@ import type { TableFilterConfig, ComparisonSectionConfig, ReferenceTableConfig, + CalculationCardsConfig, + QuarterlyTableConfig, } from '../types'; interface DetailModalProps { @@ -245,7 +247,7 @@ const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => { {/* VS 영역 */}
VS -
+

{config.vsLabel}

{typeof config.vsValue === 'number' @@ -255,6 +257,21 @@ const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => { {config.vsSubLabel && (

{config.vsSubLabel}

)} + {/* VS 세부 항목 */} + {config.vsBreakdown && config.vsBreakdown.length > 0 && ( +
+ {config.vsBreakdown.map((item, index) => ( +
+ {item.label} + + {typeof item.value === 'number' + ? formatCurrency(item.value) + (item.unit || '원') + : item.value} + +
+ ))} +
+ )}
@@ -284,6 +301,105 @@ const ComparisonSection = ({ config }: { config: ComparisonSectionConfig }) => { ); }; +/** + * 계산 카드 섹션 컴포넌트 (접대비 계산 등) + */ +const CalculationCardsSection = ({ config }: { config: CalculationCardsConfig }) => { + const isResultCard = (index: number, operator?: string) => { + // '=' 연산자가 있는 카드는 결과 카드로 강조 + return operator === '='; + }; + + return ( +
+
+

{config.title}

+ {config.subtitle && ( + {config.subtitle} + )} +
+
+ {config.cards.map((card, index) => ( +
+ {/* 연산자 표시 (첫 번째 카드 제외) */} + {index > 0 && card.operator && ( + + {card.operator} + + )} + {/* 카드 */} +
+

+ {card.label} +

+

+ {formatCurrency(card.value)}{card.unit || '원'} +

+
+
+ ))} +
+
+ ); +}; + +/** + * 분기별 테이블 섹션 컴포넌트 (접대비 현황 등) + */ +const QuarterlyTableSection = ({ config }: { config: QuarterlyTableConfig }) => { + const formatValue = (value: number | string | undefined): string => { + if (value === undefined) return '-'; + if (typeof value === 'number') return formatCurrency(value); + return value; + }; + + return ( +
+

{config.title}

+
+
+ + + + + + + + + + + + {config.rows.map((row, rowIndex) => ( + + + + + + + + + ))} + +
구분1사분기2사분기3사분기4사분기합계
{row.label}{formatValue(row.q1)}{formatValue(row.q2)}{formatValue(row.q3)}{formatValue(row.q4)}{formatValue(row.total)}
+
+ + ); +}; + /** * 참조 테이블 컴포넌트 (필터 없는 정보성 테이블) */ @@ -612,13 +728,32 @@ export function DetailModal({ isOpen, onClose, config }: DetailModalProps) { )} - {/* 참조 테이블 영역 */} + {/* 참조 테이블 영역 (단일 - 테이블 위에 표시) */} {config.referenceTable && ( )} - {/* 테이블 영역 */} + {/* 계산 카드 섹션 영역 (테이블 위에 표시) */} + {config.calculationCards && ( + + )} + + {/* 메인 테이블 영역 */} {config.table && } + + {/* 참조 테이블 영역 (다중 - 테이블 아래 표시) */} + {config.referenceTables && config.referenceTables.length > 0 && ( +
+ {config.referenceTables.map((tableConfig, index) => ( + + ))} +
+ )} + + {/* 분기별 테이블 영역 */} + {config.quarterlyTable && ( + + )} diff --git a/src/components/business/CEODashboard/sections/DailyReportSection.tsx b/src/components/business/CEODashboard/sections/DailyReportSection.tsx index 47845a9d..7d0ed9e2 100644 --- a/src/components/business/CEODashboard/sections/DailyReportSection.tsx +++ b/src/components/business/CEODashboard/sections/DailyReportSection.tsx @@ -11,10 +11,7 @@ interface DailyReportSectionProps { export function DailyReportSection({ data, onClick }: DailyReportSectionProps) { return ( - +
@@ -23,7 +20,7 @@ export function DailyReportSection({ data, onClick }: DailyReportSectionProps) {
{data.cards.map((card) => ( - + ))}
diff --git a/src/components/business/CEODashboard/sections/EntertainmentSection.tsx b/src/components/business/CEODashboard/sections/EntertainmentSection.tsx index 404fde01..92257896 100644 --- a/src/components/business/CEODashboard/sections/EntertainmentSection.tsx +++ b/src/components/business/CEODashboard/sections/EntertainmentSection.tsx @@ -6,10 +6,10 @@ import type { EntertainmentData } from '../types'; interface EntertainmentSectionProps { data: EntertainmentData; - onClick?: () => void; + onCardClick?: (cardId: string) => void; } -export function EntertainmentSection({ data, onClick }: EntertainmentSectionProps) { +export function EntertainmentSection({ data, onCardClick }: EntertainmentSectionProps) { return ( @@ -20,7 +20,7 @@ export function EntertainmentSection({ data, onClick }: EntertainmentSectionProp onCardClick?.(card.id)} /> ))}
diff --git a/src/components/business/CEODashboard/sections/WelfareSection.tsx b/src/components/business/CEODashboard/sections/WelfareSection.tsx index b1c3cb58..8fff2372 100644 --- a/src/components/business/CEODashboard/sections/WelfareSection.tsx +++ b/src/components/business/CEODashboard/sections/WelfareSection.tsx @@ -6,9 +6,10 @@ import type { WelfareData } from '../types'; interface WelfareSectionProps { data: WelfareData; + onCardClick?: (cardId: string) => void; } -export function WelfareSection({ data }: WelfareSectionProps) { +export function WelfareSection({ data, onCardClick }: WelfareSectionProps) { return ( @@ -16,7 +17,11 @@ export function WelfareSection({ data }: WelfareSectionProps) {
{data.cards.map((card) => ( - + onCardClick(card.id) : undefined} + /> ))}
diff --git a/src/components/business/CEODashboard/types.ts b/src/components/business/CEODashboard/types.ts index 0a79b5cf..b6852c2b 100644 --- a/src/components/business/CEODashboard/types.ts +++ b/src/components/business/CEODashboard/types.ts @@ -326,6 +326,13 @@ export interface ComparisonBoxConfig { borderColor: 'orange' | 'blue'; } +// VS 중앙 세부 항목 타입 +export interface VsBreakdownItem { + label: string; + value: string | number; + unit?: string; +} + // VS 비교 섹션 설정 타입 export interface ComparisonSectionConfig { leftBox: ComparisonBoxConfig; @@ -333,6 +340,7 @@ export interface ComparisonSectionConfig { vsLabel: string; vsValue: string | number; vsSubLabel?: string; + vsBreakdown?: VsBreakdownItem[]; // VS 중앙에 표시할 세부 항목들 } // 참조 테이블 설정 타입 (필터 없는 정보성 테이블) @@ -342,6 +350,37 @@ export interface ReferenceTableConfig { data: Record[]; } +// 계산 카드 아이템 타입 (접대비 계산 등) +export interface CalculationCardItem { + label: string; + value: number; + unit?: string; + operator?: '+' | '=' | '-' | '×'; // 연산자 표시 +} + +// 계산 카드 섹션 설정 타입 +export interface CalculationCardsConfig { + title: string; + subtitle?: string; // 서브타이틀 (예: "직원당 정액 금액/월 200,000원") + cards: CalculationCardItem[]; +} + +// 분기별 테이블 행 타입 +export interface QuarterlyTableRow { + label: string; + q1?: number | string; + q2?: number | string; + q3?: number | string; + q4?: number | string; + total?: number | string; +} + +// 분기별 테이블 설정 타입 +export interface QuarterlyTableConfig { + title: string; + rows: QuarterlyTableRow[]; +} + // 상세 모달 전체 설정 타입 export interface DetailModalConfig { title: string; @@ -351,6 +390,9 @@ export interface DetailModalConfig { horizontalBarChart?: HorizontalBarChartConfig; // 가로 막대 차트 (도넛 차트 대신 사용) comparisonSection?: ComparisonSectionConfig; // VS 비교 섹션 referenceTable?: ReferenceTableConfig; // 참조 테이블 (필터 없음) + referenceTables?: ReferenceTableConfig[]; // 다중 참조 테이블 + calculationCards?: CalculationCardsConfig; // 계산 카드 섹션 + quarterlyTable?: QuarterlyTableConfig; // 분기별 테이블 table?: TableConfig; } diff --git a/src/components/settings/CompanyInfoManagement/index.tsx b/src/components/settings/CompanyInfoManagement/index.tsx index c0f97b4b..454c44bc 100644 --- a/src/components/settings/CompanyInfoManagement/index.tsx +++ b/src/components/settings/CompanyInfoManagement/index.tsx @@ -393,8 +393,8 @@ export function CompanyInfoManagement() { - {/* 담당자명 / 담당자 연락처 */} -
+ {/* 담당자명 / 담당자 연락처 - 임시 주석처리 (추후 사용 가능) */} + {/*
-
+
*/} {/* 사업자등록증 / 사업자등록번호 */}
diff --git a/src/components/settings/NotificationSettings/ItemSettingsDialog.tsx b/src/components/settings/NotificationSettings/ItemSettingsDialog.tsx new file mode 100644 index 00000000..51f26267 --- /dev/null +++ b/src/components/settings/NotificationSettings/ItemSettingsDialog.tsx @@ -0,0 +1,336 @@ +'use client'; + +/** + * 알림설정 항목 설정 모달 + * + * 각 알림 카테고리와 항목의 표시/숨김을 설정합니다. + */ + +import { useState, useCallback } from 'react'; +import { X } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Switch } from '@/components/ui/switch'; +import type { ItemVisibilitySettings } from './types'; + +interface ItemSettingsDialogProps { + isOpen: boolean; + onClose: () => void; + settings: ItemVisibilitySettings; + onSave: (settings: ItemVisibilitySettings) => void; +} + +// 카테고리 섹션 컴포넌트 +interface CategorySectionProps { + title: string; + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + children: React.ReactNode; +} + +function CategorySection({ title, enabled, onEnabledChange, children }: CategorySectionProps) { + return ( +
+ {/* 카테고리 헤더 */} +
+ {title} + +
+ {/* 하위 항목 */} +
+ {children} +
+
+ ); +} + +// 항목 행 컴포넌트 +interface ItemRowProps { + label: string; + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; +} + +function ItemRow({ label, checked, onChange, disabled }: ItemRowProps) { + return ( +
+ {label} + +
+ ); +} + +export function ItemSettingsDialog({ isOpen, onClose, settings, onSave }: ItemSettingsDialogProps) { + const [localSettings, setLocalSettings] = useState(settings); + + // 모달 열릴 때 설정 동기화 + const handleOpenChange = useCallback((open: boolean) => { + if (open) { + setLocalSettings(settings); + } else { + onClose(); + } + }, [settings, onClose]); + + // 카테고리 전체 토글 + const handleCategoryToggle = useCallback(( + category: keyof ItemVisibilitySettings, + enabled: boolean + ) => { + setLocalSettings(prev => { + const categorySettings = prev[category]; + const updatedCategory: Record = { enabled }; + + // 모든 하위 항목도 같이 토글 + Object.keys(categorySettings).forEach(key => { + if (key !== 'enabled') { + updatedCategory[key] = enabled; + } + }); + + return { + ...prev, + [category]: updatedCategory as typeof categorySettings, + }; + }); + }, []); + + // 개별 항목 토글 + const handleItemToggle = useCallback(( + category: keyof ItemVisibilitySettings, + item: string, + checked: boolean + ) => { + setLocalSettings(prev => ({ + ...prev, + [category]: { + ...prev[category], + [item]: checked, + }, + })); + }, []); + + // 저장 + const handleSave = useCallback(() => { + onSave(localSettings); + onClose(); + }, [localSettings, onSave, onClose]); + + return ( + + + {/* 헤더 */} + +
+ 항목 설정 + +
+
+ +
+ {/* 공지 알림 */} + handleCategoryToggle('notice', enabled)} + > + handleItemToggle('notice', 'notice', checked)} + disabled={!localSettings.notice.enabled} + /> + handleItemToggle('notice', 'event', checked)} + disabled={!localSettings.notice.enabled} + /> + + + {/* 일정 알림 */} + handleCategoryToggle('schedule', enabled)} + > + handleItemToggle('schedule', 'vatReport', checked)} + disabled={!localSettings.schedule.enabled} + /> + handleItemToggle('schedule', 'incomeTaxReport', checked)} + disabled={!localSettings.schedule.enabled} + /> + + + {/* 거래처 알림 */} + handleCategoryToggle('vendor', enabled)} + > + handleItemToggle('vendor', 'newVendor', checked)} + disabled={!localSettings.vendor.enabled} + /> + handleItemToggle('vendor', 'creditRating', checked)} + disabled={!localSettings.vendor.enabled} + /> + + + {/* 근태 알림 */} + handleCategoryToggle('attendance', enabled)} + > + handleItemToggle('attendance', 'annualLeave', checked)} + disabled={!localSettings.attendance.enabled} + /> + handleItemToggle('attendance', 'clockIn', checked)} + disabled={!localSettings.attendance.enabled} + /> + handleItemToggle('attendance', 'late', checked)} + disabled={!localSettings.attendance.enabled} + /> + handleItemToggle('attendance', 'absent', checked)} + disabled={!localSettings.attendance.enabled} + /> + + + {/* 수주/발주 알림 */} + handleCategoryToggle('order', enabled)} + > + handleItemToggle('order', 'salesOrder', checked)} + disabled={!localSettings.order.enabled} + /> + handleItemToggle('order', 'purchaseOrder', checked)} + disabled={!localSettings.order.enabled} + /> + + + {/* 전자결재 알림 */} + handleCategoryToggle('approval', enabled)} + > + handleItemToggle('approval', 'approvalRequest', checked)} + disabled={!localSettings.approval.enabled} + /> + handleItemToggle('approval', 'draftApproved', checked)} + disabled={!localSettings.approval.enabled} + /> + handleItemToggle('approval', 'draftRejected', checked)} + disabled={!localSettings.approval.enabled} + /> + handleItemToggle('approval', 'draftCompleted', checked)} + disabled={!localSettings.approval.enabled} + /> + + + {/* 생산 알림 */} + handleCategoryToggle('production', enabled)} + > + handleItemToggle('production', 'safetyStock', checked)} + disabled={!localSettings.production.enabled} + /> + handleItemToggle('production', 'productionComplete', checked)} + disabled={!localSettings.production.enabled} + /> + +
+ + {/* 하단 버튼 */} +
+ + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/settings/NotificationSettings/index.tsx b/src/components/settings/NotificationSettings/index.tsx index f99e980d..7716eae3 100644 --- a/src/components/settings/NotificationSettings/index.tsx +++ b/src/components/settings/NotificationSettings/index.tsx @@ -4,12 +4,13 @@ * 알림설정 페이지 * * 각 알림 유형별로 ON/OFF 토글, 알림 소리 선택, 이메일 알림 체크박스를 제공합니다. + * 항목 설정 기능으로 표시할 알림 카테고리/항목을 선택할 수 있습니다. */ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; -import { Bell, Save, Play } from 'lucide-react'; +import { Bell, Save, Play, Settings } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardTitle } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; @@ -22,9 +23,10 @@ import { SelectValue, } from '@/components/ui/select'; import { toast } from 'sonner'; -import type { NotificationSettings, NotificationItem, SoundType } from './types'; -import { SOUND_OPTIONS } from './types'; +import type { NotificationSettings, NotificationItem, SoundType, ItemVisibilitySettings } from './types'; +import { SOUND_OPTIONS, DEFAULT_ITEM_VISIBILITY } from './types'; import { saveNotificationSettings } from './actions'; +import { ItemSettingsDialog } from './ItemSettingsDialog'; // 미리듣기 함수 function playPreviewSound(soundType: SoundType) { @@ -153,9 +155,28 @@ interface NotificationSettingsManagementProps { initialData: NotificationSettings; } +const ITEM_VISIBILITY_STORAGE_KEY = 'notification-item-visibility'; + export function NotificationSettingsManagement({ initialData }: NotificationSettingsManagementProps) { const [settings, setSettings] = useState(initialData); + // 항목 설정 (표시/숨김) + const [itemVisibility, setItemVisibility] = useState(() => { + if (typeof window === 'undefined') return DEFAULT_ITEM_VISIBILITY; + const saved = localStorage.getItem(ITEM_VISIBILITY_STORAGE_KEY); + return saved ? JSON.parse(saved) : DEFAULT_ITEM_VISIBILITY; + }); + + // 항목 설정 모달 상태 + const [isItemSettingsOpen, setIsItemSettingsOpen] = useState(false); + + // 항목 설정 저장 + const handleItemVisibilitySave = useCallback((newSettings: ItemVisibilitySettings) => { + setItemVisibility(newSettings); + localStorage.setItem(ITEM_VISIBILITY_STORAGE_KEY, JSON.stringify(newSettings)); + toast.success('항목 설정이 저장되었습니다.'); + }, []); + // 공지 알림 핸들러 const handleNoticeEnabledChange = (enabled: boolean) => { setSettings(prev => ({ @@ -342,185 +363,245 @@ export function NotificationSettingsManagement({ initialData }: NotificationSett icon={Bell} /> + {/* 상단 버튼 영역 */} +
+ + +
+
{/* 공지 알림 */} - - handleNoticeItemChange('notice', item)} - disabled={!settings.notice.enabled} - /> - handleNoticeItemChange('event', item)} - disabled={!settings.notice.enabled} - /> - + {itemVisibility.notice.enabled && ( + + {itemVisibility.notice.notice && ( + handleNoticeItemChange('notice', item)} + disabled={!settings.notice.enabled} + /> + )} + {itemVisibility.notice.event && ( + handleNoticeItemChange('event', item)} + disabled={!settings.notice.enabled} + /> + )} + + )} {/* 일정 알림 */} - - handleScheduleItemChange('vatReport', item)} - disabled={!settings.schedule.enabled} - /> - handleScheduleItemChange('incomeTaxReport', item)} - disabled={!settings.schedule.enabled} - /> - + {itemVisibility.schedule.enabled && ( + + {itemVisibility.schedule.vatReport && ( + handleScheduleItemChange('vatReport', item)} + disabled={!settings.schedule.enabled} + /> + )} + {itemVisibility.schedule.incomeTaxReport && ( + handleScheduleItemChange('incomeTaxReport', item)} + disabled={!settings.schedule.enabled} + /> + )} + + )} {/* 거래처 알림 */} - - handleVendorItemChange('newVendor', item)} - disabled={!settings.vendor.enabled} - /> - handleVendorItemChange('creditRating', item)} - disabled={!settings.vendor.enabled} - /> - + {itemVisibility.vendor.enabled && ( + + {itemVisibility.vendor.newVendor && ( + handleVendorItemChange('newVendor', item)} + disabled={!settings.vendor.enabled} + /> + )} + {itemVisibility.vendor.creditRating && ( + handleVendorItemChange('creditRating', item)} + disabled={!settings.vendor.enabled} + /> + )} + + )} {/* 근태 알림 */} - - handleAttendanceItemChange('annualLeave', item)} - disabled={!settings.attendance.enabled} - /> - handleAttendanceItemChange('clockIn', item)} - disabled={!settings.attendance.enabled} - /> - handleAttendanceItemChange('late', item)} - disabled={!settings.attendance.enabled} - /> - handleAttendanceItemChange('absent', item)} - disabled={!settings.attendance.enabled} - /> - + {itemVisibility.attendance.enabled && ( + + {itemVisibility.attendance.annualLeave && ( + handleAttendanceItemChange('annualLeave', item)} + disabled={!settings.attendance.enabled} + /> + )} + {itemVisibility.attendance.clockIn && ( + handleAttendanceItemChange('clockIn', item)} + disabled={!settings.attendance.enabled} + /> + )} + {itemVisibility.attendance.late && ( + handleAttendanceItemChange('late', item)} + disabled={!settings.attendance.enabled} + /> + )} + {itemVisibility.attendance.absent && ( + handleAttendanceItemChange('absent', item)} + disabled={!settings.attendance.enabled} + /> + )} + + )} {/* 수주/발주 알림 */} - - handleOrderItemChange('salesOrder', item)} - disabled={!settings.order.enabled} - /> - handleOrderItemChange('purchaseOrder', item)} - disabled={!settings.order.enabled} - /> - handleOrderItemChange('approvalRequest', item)} - disabled={!settings.order.enabled} - /> - + {itemVisibility.order.enabled && ( + + {itemVisibility.order.salesOrder && ( + handleOrderItemChange('salesOrder', item)} + disabled={!settings.order.enabled} + /> + )} + {itemVisibility.order.purchaseOrder && ( + handleOrderItemChange('purchaseOrder', item)} + disabled={!settings.order.enabled} + /> + )} + + )} {/* 전자결재 알림 */} - - handleApprovalItemChange('approvalRequest', item)} - disabled={!settings.approval.enabled} - /> - handleApprovalItemChange('draftApproved', item)} - disabled={!settings.approval.enabled} - /> - handleApprovalItemChange('draftRejected', item)} - disabled={!settings.approval.enabled} - /> - handleApprovalItemChange('draftCompleted', item)} - disabled={!settings.approval.enabled} - /> - + {itemVisibility.approval.enabled && ( + + {itemVisibility.approval.approvalRequest && ( + handleApprovalItemChange('approvalRequest', item)} + disabled={!settings.approval.enabled} + /> + )} + {itemVisibility.approval.draftApproved && ( + handleApprovalItemChange('draftApproved', item)} + disabled={!settings.approval.enabled} + /> + )} + {itemVisibility.approval.draftRejected && ( + handleApprovalItemChange('draftRejected', item)} + disabled={!settings.approval.enabled} + /> + )} + {itemVisibility.approval.draftCompleted && ( + handleApprovalItemChange('draftCompleted', item)} + disabled={!settings.approval.enabled} + /> + )} + + )} {/* 생산 알림 */} - - handleProductionItemChange('safetyStock', item)} - disabled={!settings.production.enabled} - /> - handleProductionItemChange('productionComplete', item)} - disabled={!settings.production.enabled} - /> - - - {/* 저장 버튼 */} -
- -
+ {itemVisibility.production.enabled && ( + + {itemVisibility.production.safetyStock && ( + handleProductionItemChange('safetyStock', item)} + disabled={!settings.production.enabled} + /> + )} + {itemVisibility.production.productionComplete && ( + handleProductionItemChange('productionComplete', item)} + disabled={!settings.production.enabled} + /> + )} + + )}
+ + {/* 항목 설정 모달 */} + setIsItemSettingsOpen(false)} + settings={itemVisibility} + onSave={handleItemVisibilitySave} + /> ); } \ No newline at end of file diff --git a/src/components/settings/NotificationSettings/types.ts b/src/components/settings/NotificationSettings/types.ts index b10bebd6..b9b878e6 100644 --- a/src/components/settings/NotificationSettings/types.ts +++ b/src/components/settings/NotificationSettings/types.ts @@ -112,6 +112,72 @@ export interface NotificationSettings { production: ProductionNotificationSettings; } +// ===== 항목 설정 (표시/숨김) 타입 ===== + +// 공지 알림 항목 설정 +export interface NoticeItemVisibility { + enabled: boolean; + notice: boolean; // 공지사항 알림 + event: boolean; // 이벤트 알림 +} + +// 일정 알림 항목 설정 +export interface ScheduleItemVisibility { + enabled: boolean; + vatReport: boolean; // 부가세 신고 알림 + incomeTaxReport: boolean; // 종합소득세 신고 알림 +} + +// 거래처 알림 항목 설정 +export interface VendorItemVisibility { + enabled: boolean; + newVendor: boolean; // 신규 업체 등록 알림 + creditRating: boolean; // 신용등급 알림 +} + +// 근태 알림 항목 설정 +export interface AttendanceItemVisibility { + enabled: boolean; + annualLeave: boolean; // 연차 알림 + clockIn: boolean; // 출근 알림 + late: boolean; // 지각 알림 + absent: boolean; // 결근 알림 +} + +// 수주/발주 알림 항목 설정 +export interface OrderItemVisibility { + enabled: boolean; + salesOrder: boolean; // 수주 알림 + purchaseOrder: boolean; // 발주 알림 +} + +// 전자결재 알림 항목 설정 +export interface ApprovalItemVisibility { + enabled: boolean; + approvalRequest: boolean; // 결재요청 알림 + draftApproved: boolean; // 기안 > 승인 알림 + draftRejected: boolean; // 기안 > 반려 알림 + draftCompleted: boolean; // 기안 > 완료 알림 +} + +// 생산 알림 항목 설정 +export interface ProductionItemVisibility { + enabled: boolean; + safetyStock: boolean; // 안전재고 알림 + productionComplete: boolean; // 생산완료 알림 +} + +// 전체 항목 설정 +export interface ItemVisibilitySettings { + notice: NoticeItemVisibility; + schedule: ScheduleItemVisibility; + vendor: VendorItemVisibility; + attendance: AttendanceItemVisibility; + order: OrderItemVisibility; + approval: ApprovalItemVisibility; + production: ProductionItemVisibility; +} + // 기본값 export const DEFAULT_NOTIFICATION_ITEM: NotificationItem = { enabled: false, @@ -160,4 +226,47 @@ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { safetyStock: { enabled: false, email: false, soundType: 'default' }, productionComplete: { enabled: true, email: false, soundType: 'sam_voice' }, }, +}; + +// 항목 설정 기본값 (모두 표시) +export const DEFAULT_ITEM_VISIBILITY: ItemVisibilitySettings = { + notice: { + enabled: true, + notice: true, + event: true, + }, + schedule: { + enabled: true, + vatReport: true, + incomeTaxReport: true, + }, + vendor: { + enabled: true, + newVendor: true, + creditRating: true, + }, + attendance: { + enabled: true, + annualLeave: true, + clockIn: true, + late: true, + absent: true, + }, + order: { + enabled: true, + salesOrder: true, + purchaseOrder: true, + }, + approval: { + enabled: true, + approvalRequest: true, + draftApproved: true, + draftRejected: true, + draftCompleted: true, + }, + production: { + enabled: true, + safetyStock: true, + productionComplete: true, + }, }; \ No newline at end of file