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/claudedocs/[REF-2026-01-09] server-to-client-component-migration-checklist.md b/claudedocs/[REF-2026-01-09] server-to-client-component-migration-checklist.md new file mode 100644 index 00000000..073dc7e5 --- /dev/null +++ b/claudedocs/[REF-2026-01-09] server-to-client-component-migration-checklist.md @@ -0,0 +1,146 @@ +# Server Component → Client Component 마이그레이션 계획서 + +## 배경 +- **문제**: Server Component에서 API 호출 시 토큰 갱신(쿠키 수정)이 불가능 +- **원인**: Next.js 15에서 Server Component 렌더링 중 쿠키 수정 금지 +- **영향**: 토큰 만료 시 기본값 표시 → 데이터 덮어쓰기 위험 +- **결정**: 폐쇄형 사이트로 SEO 불필요, Client Component로 전환 + +## 변경 대상 (53개 페이지) + +### Settings (4개) +- [ ] settings/notification-settings/page.tsx +- [ ] settings/popup-management/page.tsx +- [ ] settings/permissions/[id]/page.tsx +- [ ] settings/account-info/page.tsx + +### Accounting (9개) +- [ ] accounting/vendors/page.tsx +- [ ] accounting/sales/page.tsx +- [ ] accounting/deposits/page.tsx +- [ ] accounting/bills/page.tsx +- [ ] accounting/withdrawals/page.tsx +- [ ] accounting/expected-expenses/page.tsx +- [ ] accounting/bad-debt-collection/page.tsx +- [ ] accounting/bad-debt-collection/[id]/page.tsx +- [ ] accounting/bad-debt-collection/[id]/edit/page.tsx + +### Sales (4개) +- [ ] sales/quote-management/page.tsx +- [ ] sales/pricing-management/page.tsx +- [ ] sales/pricing-management/[id]/edit/page.tsx +- [ ] sales/pricing-management/create/page.tsx + +### Production (3개) +- [ ] production/work-orders/[id]/page.tsx +- [ ] production/screen-production/page.tsx +- [ ] production/screen-production/[id]/page.tsx + +### Quality (1개) +- [ ] quality/inspections/[id]/page.tsx + +### Master Data (2개) +- [ ] master-data/process-management/[id]/page.tsx +- [ ] master-data/process-management/[id]/edit/page.tsx + +### Material (2개) +- [ ] material/stock-status/[id]/page.tsx +- [ ] material/receiving-management/[id]/page.tsx + +### Outbound (2개) +- [ ] outbound/shipments/[id]/page.tsx +- [ ] outbound/shipments/[id]/edit/page.tsx + +### Construction - Order (8개) +- [ ] construction/order/order-management/[id]/page.tsx +- [ ] construction/order/order-management/[id]/edit/page.tsx +- [ ] construction/order/site-management/[id]/page.tsx +- [ ] construction/order/site-management/[id]/edit/page.tsx +- [ ] construction/order/structure-review/[id]/page.tsx +- [ ] construction/order/structure-review/[id]/edit/page.tsx +- [ ] construction/order/base-info/items/[id]/page.tsx +- [ ] construction/order/base-info/pricing/[id]/page.tsx +- [ ] construction/order/base-info/pricing/[id]/edit/page.tsx +- [ ] construction/order/base-info/labor/[id]/page.tsx + +### Construction - Project/Bidding (8개) +- [ ] construction/project/bidding/[id]/page.tsx +- [ ] construction/project/bidding/[id]/edit/page.tsx +- [ ] construction/project/bidding/site-briefings/[id]/page.tsx +- [ ] construction/project/bidding/site-briefings/[id]/edit/page.tsx +- [ ] construction/project/bidding/estimates/[id]/page.tsx +- [ ] construction/project/bidding/estimates/[id]/edit/page.tsx +- [ ] construction/project/bidding/partners/[id]/page.tsx +- [ ] construction/project/bidding/partners/[id]/edit/page.tsx + +### Construction - Project/Contract (4개) +- [ ] construction/project/contract/[id]/page.tsx +- [ ] construction/project/contract/[id]/edit/page.tsx +- [ ] construction/project/contract/handover-report/[id]/page.tsx +- [ ] construction/project/contract/handover-report/[id]/edit/page.tsx + +### Others (4개) +- [ ] payment-history/page.tsx +- [ ] subscription/page.tsx +- [ ] dev/test-urls/page.tsx +- [ ] dev/construction-test-urls/page.tsx + +## 변환 패턴 + +### Before (Server Component) +```typescript +import { Component } from '@/components/...'; +import { getData } from '@/components/.../actions'; + +export default async function Page() { + const result = await getData(); + return ; +} +``` + +### After (Client Component) +```typescript +'use client'; + +import { useEffect, useState } from 'react'; +import { Component } from '@/components/...'; +import { getData } from '@/components/.../actions'; +import { DEFAULT_DATA } from '@/components/.../types'; + +export default function Page() { + const [data, setData] = useState(DEFAULT_DATA); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getData() + .then(result => { + if (result.success) { + setData(result.data); + } + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return
로딩 중...
; + } + + return ; +} +``` + +## 추가 작업 + +### 1. RULES.md 업데이트 +- Client Component 사용 원칙 추가 +- SEO 불필요 폐쇄형 사이트 명시 + +### 2. fetch-wrapper.ts 정리 +- skipTokenRefresh 옵션 제거 (불필요해짐) + +### 3. actions.ts 정리 +- skipTokenRefresh 관련 코드 제거 + +## 진행 상태 +- 시작일: 2026-01-09 +- 현재 상태: 진행 중 diff --git a/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/edit/page.tsx b/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/edit/page.tsx index 34a587ff..2d964ca9 100644 --- a/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/edit/page.tsx @@ -1,18 +1,58 @@ -import { notFound } from 'next/navigation'; +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions'; import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail'; +import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types'; interface EditBadDebtPageProps { params: Promise<{ id: string }>; } -export default async function EditBadDebtPage({ params }: EditBadDebtPageProps) { - const { id } = await params; - const badDebt = await getBadDebtById(id); +export default function EditBadDebtPage({ params }: EditBadDebtPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - if (!badDebt) { - notFound(); + useEffect(() => { + getBadDebtById(id) + .then(result => { + if (result) { + setData(result); + } else { + setError('데이터를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('데이터를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); } - return ; + if (error || !data) { + return ( +
+
{error || '데이터를 찾을 수 없습니다.'}
+ +
+ ); + } + + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/page.tsx b/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/page.tsx index 9c854537..282395bd 100644 --- a/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bad-debt-collection/[id]/page.tsx @@ -1,18 +1,58 @@ -import { notFound } from 'next/navigation'; +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { getBadDebtById } from '@/components/accounting/BadDebtCollection/actions'; import { BadDebtDetail } from '@/components/accounting/BadDebtCollection/BadDebtDetail'; +import type { BadDebtRecord } from '@/components/accounting/BadDebtCollection/types'; interface BadDebtDetailPageProps { params: Promise<{ id: string }>; } -export default async function BadDebtDetailPage({ params }: BadDebtDetailPageProps) { - const { id } = await params; - const badDebt = await getBadDebtById(id); +export default function BadDebtDetailPage({ params }: BadDebtDetailPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - if (!badDebt) { - notFound(); + useEffect(() => { + getBadDebtById(id) + .then(result => { + if (result) { + setData(result); + } else { + setError('데이터를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('데이터를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); } - return ; + if (error || !data) { + return ( +
+
{error || '데이터를 찾을 수 없습니다.'}
+ +
+ ); + } + + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx b/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx index 99cff423..e38a1d7a 100644 --- a/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bad-debt-collection/page.tsx @@ -1,3 +1,5 @@ +'use client'; + /** * 악성채권 추심관리 목록 페이지 * @@ -7,23 +9,48 @@ * - GET /api/v1/bad-debts/summary - 통계 정보 */ +import { useEffect, useState } from 'react'; import { BadDebtCollection } from '@/components/accounting/BadDebtCollection'; import { getBadDebts, getBadDebtSummary } from '@/components/accounting/BadDebtCollection/actions'; +import type { BadDebtSummary } from '@/components/accounting/BadDebtCollection/types'; -export default async function BadDebtCollectionPage() { - // 서버에서 데이터 병렬 조회 - const [badDebts, summary] = await Promise.all([ - getBadDebts({ size: 100 }), - getBadDebtSummary(), - ]); +const DEFAULT_SUMMARY: BadDebtSummary = { + totalCount: 0, + totalAmount: 0, + collectedAmount: 0, + pendingAmount: 0, + collectionRate: 0, +}; - console.log('[BadDebtPage] Data count:', badDebts.length); - console.log('[BadDebtPage] Summary:', summary); +export default function BadDebtCollectionPage() { + const [data, setData] = useState>>([]); + const [summary, setSummary] = useState(DEFAULT_SUMMARY); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + Promise.all([ + getBadDebts({ size: 100 }), + getBadDebtSummary(), + ]) + .then(([badDebts, summaryResult]) => { + setData(badDebts); + setSummary(summaryResult); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); -} +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/bills/page.tsx b/src/app/[locale]/(protected)/accounting/bills/page.tsx index 052cd949..8a196820 100644 --- a/src/app/[locale]/(protected)/accounting/bills/page.tsx +++ b/src/app/[locale]/(protected)/accounting/bills/page.tsx @@ -1,106 +1,46 @@ -import { cookies } from 'next/headers'; +'use client'; + +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; import { BillManagementClient } from '@/components/accounting/BillManagement/BillManagementClient'; -import type { BillRecord, BillApiData } from '@/components/accounting/BillManagement/types'; -import { transformApiToFrontend } from '@/components/accounting/BillManagement/types'; +import { getBills } from '@/components/accounting/BillManagement/actions'; +import type { BillRecord } from '@/components/accounting/BillManagement/types'; -interface BillsPageProps { - searchParams: Promise<{ - vendorId?: string; - type?: string; - page?: string; - }>; -} +const DEFAULT_PAGINATION = { + currentPage: 1, + lastPage: 1, + perPage: 20, + total: 0, +}; -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; +export default function BillsPage() { + const searchParams = useSearchParams(); + const vendorId = searchParams.get('vendorId') || undefined; + const billType = searchParams.get('type') || 'received'; + const page = searchParams.get('page') ? parseInt(searchParams.get('page')!) : 1; - return { - 'Accept': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} + const [data, setData] = useState([]); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const [isLoading, setIsLoading] = useState(true); -async function getBills(params: { - billType?: string; - page?: number; -}): Promise<{ - data: BillRecord[]; - pagination: { - currentPage: number; - lastPage: number; - perPage: number; - total: number; - }; -}> { - try { - const headers = await getApiHeaders(); - const queryParams = new URLSearchParams(); + useEffect(() => { + getBills({ billType, page, perPage: 20 }) + .then(result => { + if (result.success) { + setData(result.data); + setPagination(result.pagination); + } + }) + .finally(() => setIsLoading(false)); + }, [billType, page]); - if (params.billType && params.billType !== 'all') { - queryParams.append('bill_type', params.billType); - } - if (params.page) { - queryParams.append('page', String(params.page)); - } - queryParams.append('per_page', '20'); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/bills?${queryParams.toString()}`, - { method: 'GET', headers, cache: 'no-store' } + if (isLoading) { + return ( +
+
로딩 중...
+
); - - if (!response.ok) { - console.error('[BillsPage] Fetch error:', response.status); - return { - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - }; - } - - const result = await response.json(); - - if (!result.success) { - return { - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - }; - } - - const paginatedData = result.data as { - data: BillApiData[]; - current_page: number; - last_page: number; - per_page: number; - total: number; - }; - - return { - data: paginatedData.data.map(transformApiToFrontend), - pagination: { - currentPage: paginatedData.current_page, - lastPage: paginatedData.last_page, - perPage: paginatedData.per_page, - total: paginatedData.total, - }, - }; - } catch (error) { - console.error('[BillsPage] Fetch error:', error); - return { - data: [], - pagination: { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }, - }; } -} - -export default async function BillsPage({ searchParams }: BillsPageProps) { - const params = await searchParams; - const vendorId = params.vendorId; - const billType = params.type || 'received'; - const page = params.page ? parseInt(params.page) : 1; - - const { data, pagination } = await getBills({ billType, page }); return ( ); -} +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/deposits/page.tsx b/src/app/[locale]/(protected)/accounting/deposits/page.tsx index e730fff5..231333bd 100644 --- a/src/app/[locale]/(protected)/accounting/deposits/page.tsx +++ b/src/app/[locale]/(protected)/accounting/deposits/page.tsx @@ -1,13 +1,42 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { DepositManagement } from '@/components/accounting/DepositManagement'; import { getDeposits } from '@/components/accounting/DepositManagement/actions'; -export default async function DepositsPage() { - const result = await getDeposits({ perPage: 100 }); +const DEFAULT_PAGINATION = { + currentPage: 1, + lastPage: 1, + perPage: 100, + total: 0, +}; + +export default function DepositsPage() { + const [data, setData] = useState>['data']>([]); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getDeposits({ perPage: 100 }) + .then(result => { + setData(result.data); + setPagination(result.pagination); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/expected-expenses/page.tsx b/src/app/[locale]/(protected)/accounting/expected-expenses/page.tsx index f3125070..a9ae932e 100644 --- a/src/app/[locale]/(protected)/accounting/expected-expenses/page.tsx +++ b/src/app/[locale]/(protected)/accounting/expected-expenses/page.tsx @@ -1,19 +1,47 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { ExpectedExpenseManagement } from '@/components/accounting/ExpectedExpenseManagement'; import { getExpectedExpenses } from '@/components/accounting/ExpectedExpenseManagement/actions'; -export default async function ExpectedExpensesPage() { - // 서버에서 초기 데이터 로드 - const result = await getExpectedExpenses({ - page: 1, - perPage: 50, - sortBy: 'expected_payment_date', - sortDir: 'asc', - }); +const DEFAULT_PAGINATION = { + currentPage: 1, + lastPage: 1, + perPage: 50, + total: 0, +}; + +export default function ExpectedExpensesPage() { + const [data, setData] = useState>['data']>([]); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getExpectedExpenses({ + page: 1, + perPage: 50, + sortBy: 'expected_payment_date', + sortDir: 'asc', + }) + .then(result => { + setData(result.data); + setPagination(result.pagination); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); -} +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/sales/page.tsx b/src/app/[locale]/(protected)/accounting/sales/page.tsx index 6044332a..fd3f8d32 100644 --- a/src/app/[locale]/(protected)/accounting/sales/page.tsx +++ b/src/app/[locale]/(protected)/accounting/sales/page.tsx @@ -1,13 +1,42 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { SalesManagement } from '@/components/accounting/SalesManagement'; import { getSales } from '@/components/accounting/SalesManagement/actions'; -export default async function SalesPage() { - const result = await getSales({ perPage: 100 }); +const DEFAULT_PAGINATION = { + currentPage: 1, + lastPage: 1, + perPage: 100, + total: 0, +}; + +export default function SalesPage() { + const [data, setData] = useState>['data']>([]); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getSales({ perPage: 100 }) + .then(result => { + setData(result.data); + setPagination(result.pagination); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/vendors/page.tsx b/src/app/[locale]/(protected)/accounting/vendors/page.tsx index b832e266..0267f640 100644 --- a/src/app/[locale]/(protected)/accounting/vendors/page.tsx +++ b/src/app/[locale]/(protected)/accounting/vendors/page.tsx @@ -1,13 +1,35 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { VendorManagement } from '@/components/accounting/VendorManagement'; import { getClients } from '@/components/accounting/VendorManagement/actions'; -export default async function VendorsPage() { - const result = await getClients({ size: 100 }); +export default function VendorsPage() { + const [data, setData] = useState>['data']>([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getClients({ size: 100 }) + .then(result => { + setData(result.data); + setTotal(result.total); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/accounting/withdrawals/page.tsx b/src/app/[locale]/(protected)/accounting/withdrawals/page.tsx index de7238bd..adb8f0e9 100644 --- a/src/app/[locale]/(protected)/accounting/withdrawals/page.tsx +++ b/src/app/[locale]/(protected)/accounting/withdrawals/page.tsx @@ -1,13 +1,42 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { WithdrawalManagement } from '@/components/accounting/WithdrawalManagement'; import { getWithdrawals } from '@/components/accounting/WithdrawalManagement/actions'; -export default async function WithdrawalsPage() { - const result = await getWithdrawals({ perPage: 100 }); +const DEFAULT_PAGINATION = { + currentPage: 1, + lastPage: 1, + perPage: 100, + total: 0, +}; + +export default function WithdrawalsPage() { + const [data, setData] = useState>['data']>([]); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getWithdrawals({ perPage: 100 }) + .then(result => { + setData(result.data); + setPagination(result.pagination); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/items/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/items/[id]/page.tsx index bb65ce7f..ed9f7553 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/items/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/items/[id]/page.tsx @@ -1,14 +1,18 @@ +'use client'; + +import { use } from 'react'; +import { useSearchParams } from 'next/navigation'; import { ItemDetailClient } from '@/components/business/construction/item-management'; interface ItemDetailPageProps { params: Promise<{ id: string }>; - searchParams: Promise<{ mode?: string }>; } -export default async function ItemDetailPage({ params, searchParams }: ItemDetailPageProps) { - const { id } = await params; - const { mode } = await searchParams; +export default function ItemDetailPage({ params }: ItemDetailPageProps) { + const { id } = use(params); + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); const isEditMode = mode === 'edit'; return ; -} +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx index 173ab3b9..45e54d8e 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/labor/[id]/page.tsx @@ -1,13 +1,17 @@ +'use client'; + +import { use } from 'react'; +import { useSearchParams } from 'next/navigation'; import { LaborDetailClient } from '@/components/business/construction/labor-management'; interface LaborDetailPageProps { params: Promise<{ id: string }>; - searchParams: Promise<{ mode?: string }>; } -export default async function LaborDetailPage({ params, searchParams }: LaborDetailPageProps) { - const { id } = await params; - const { mode } = await searchParams; +export default function LaborDetailPage({ params }: LaborDetailPageProps) { + const { id } = use(params); + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); const isEditMode = mode === 'edit'; return ; diff --git a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/edit/page.tsx index 9e8677e5..5298d713 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/edit/page.tsx @@ -1,11 +1,14 @@ +'use client'; + +import { use } from 'react'; import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient'; interface PageProps { params: Promise<{ id: string }>; } -export default async function PricingEditPage({ params }: PageProps) { - const { id } = await params; +export default function PricingEditPage({ params }: PageProps) { + const { id } = use(params); return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx index 35e3a13a..a5097a30 100644 --- a/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/base-info/pricing/[id]/page.tsx @@ -1,11 +1,14 @@ +'use client'; + +import { use } from 'react'; import PricingDetailClient from '@/components/business/construction/pricing-management/PricingDetailClient'; interface PageProps { params: Promise<{ id: string }>; } -export default async function PricingDetailPage({ params }: PageProps) { - const { id } = await params; +export default function PricingDetailPage({ params }: PageProps) { + const { id } = use(params); return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/order-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/order/order-management/[id]/edit/page.tsx index c7965af1..6416e183 100644 --- a/src/app/[locale]/(protected)/construction/order/order-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/order-management/[id]/edit/page.tsx @@ -1,19 +1,57 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { OrderDetailForm } from '@/components/business/construction/order-management'; import { getOrderDetailFull } from '@/components/business/construction/order-management/actions'; -import { notFound } from 'next/navigation'; interface OrderEditPageProps { params: Promise<{ id: string }>; } -export default async function OrderEditPage({ params }: OrderEditPageProps) { - const { id } = await params; +export default function OrderEditPage({ params }: OrderEditPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - const result = await getOrderDetailFull(id); + useEffect(() => { + getOrderDetailFull(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('주문 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('주문 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); - if (!result.success || !result.data) { - notFound(); + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); } - return ; + if (error || !data) { + return ( +
+
{error || '주문 정보를 찾을 수 없습니다.'}
+ +
+ ); + } + + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/order-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/order-management/[id]/page.tsx index 95f9d08a..e3f0018f 100644 --- a/src/app/[locale]/(protected)/construction/order/order-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/order-management/[id]/page.tsx @@ -1,19 +1,57 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { OrderDetailForm } from '@/components/business/construction/order-management'; import { getOrderDetailFull } from '@/components/business/construction/order-management/actions'; -import { notFound } from 'next/navigation'; interface OrderDetailPageProps { params: Promise<{ id: string }>; } -export default async function OrderDetailPage({ params }: OrderDetailPageProps) { - const { id } = await params; +export default function OrderDetailPage({ params }: OrderDetailPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - const result = await getOrderDetailFull(id); + useEffect(() => { + getOrderDetailFull(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('주문 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('주문 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); - if (!result.success || !result.data) { - notFound(); + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); } - return ; + if (error || !data) { + return ( +
+
{error || '주문 정보를 찾을 수 없습니다.'}
+ +
+ ); + } + + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/site-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/order/site-management/[id]/edit/page.tsx index 2f7038ad..27e86534 100644 --- a/src/app/[locale]/(protected)/construction/order/site-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/site-management/[id]/edit/page.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm'; // 목업 데이터 @@ -17,11 +20,24 @@ interface PageProps { params: Promise<{ id: string }>; } -export default async function SiteEditPage({ params }: PageProps) { - const { id } = await params; +export default function SiteEditPage({ params }: PageProps) { + const { id } = use(params); + const [site, setSite] = useState(null); + const [isLoading, setIsLoading] = useState(true); - // TODO: API에서 현장 정보 조회 - const site = { ...MOCK_SITE, id }; + useEffect(() => { + // TODO: API에서 현장 정보 조회 + setSite({ ...MOCK_SITE, id }); + setIsLoading(false); + }, [id]); + + if (isLoading || !site) { + return ( +
+
로딩 중...
+
+ ); + } return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/site-management/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/site-management/[id]/page.tsx index efbf1981..0ba0ee0d 100644 --- a/src/app/[locale]/(protected)/construction/order/site-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/site-management/[id]/page.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; import SiteDetailForm from '@/components/business/construction/site-management/SiteDetailForm'; // 목업 데이터 @@ -17,11 +20,24 @@ interface PageProps { params: Promise<{ id: string }>; } -export default async function SiteDetailPage({ params }: PageProps) { - const { id } = await params; +export default function SiteDetailPage({ params }: PageProps) { + const { id } = use(params); + const [site, setSite] = useState(null); + const [isLoading, setIsLoading] = useState(true); - // TODO: API에서 현장 정보 조회 - const site = { ...MOCK_SITE, id }; + useEffect(() => { + // TODO: API에서 현장 정보 조회 + setSite({ ...MOCK_SITE, id }); + setIsLoading(false); + }, [id]); + + if (isLoading || !site) { + return ( +
+
로딩 중...
+
+ ); + } return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/structure-review/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/order/structure-review/[id]/edit/page.tsx index c30e82ce..c83f9720 100644 --- a/src/app/[locale]/(protected)/construction/order/structure-review/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/structure-review/[id]/edit/page.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm'; // 목업 데이터 @@ -22,11 +25,24 @@ interface PageProps { params: Promise<{ id: string }>; } -export default async function StructureReviewEditPage({ params }: PageProps) { - const { id } = await params; +export default function StructureReviewEditPage({ params }: PageProps) { + const { id } = use(params); + const [review, setReview] = useState(null); + const [isLoading, setIsLoading] = useState(true); - // TODO: API에서 구조검토 정보 조회 - const review = { ...MOCK_REVIEW, id }; + useEffect(() => { + // TODO: API에서 구조검토 정보 조회 + setReview({ ...MOCK_REVIEW, id }); + setIsLoading(false); + }, [id]); + + if (isLoading || !review) { + return ( +
+
로딩 중...
+
+ ); + } return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/structure-review/[id]/page.tsx b/src/app/[locale]/(protected)/construction/order/structure-review/[id]/page.tsx index bd234ce4..31d8886e 100644 --- a/src/app/[locale]/(protected)/construction/order/structure-review/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/order/structure-review/[id]/page.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; import StructureReviewDetailForm from '@/components/business/construction/structure-review/StructureReviewDetailForm'; // 목업 데이터 @@ -22,11 +25,24 @@ interface PageProps { params: Promise<{ id: string }>; } -export default async function StructureReviewDetailPage({ params }: PageProps) { - const { id } = await params; +export default function StructureReviewDetailPage({ params }: PageProps) { + const { id } = use(params); + const [review, setReview] = useState(null); + const [isLoading, setIsLoading] = useState(true); - // TODO: API에서 구조검토 정보 조회 - const review = { ...MOCK_REVIEW, id }; + useEffect(() => { + // TODO: API에서 구조검토 정보 조회 + setReview({ ...MOCK_REVIEW, id }); + setIsLoading(false); + }, [id]); + + if (isLoading || !review) { + return ( +
+
로딩 중...
+
+ ); + } return ; -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/construction/project/bidding/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/[id]/edit/page.tsx index 6bd2c284..782b6382 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/[id]/edit/page.tsx @@ -1,18 +1,38 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding'; interface BiddingEditPageProps { params: Promise<{ id: string }>; } -export default async function BiddingEditPage({ params }: BiddingEditPageProps) { - const { id } = await params; - const result = await getBiddingDetail(id); +export default function BiddingEditPage({ params }: BiddingEditPageProps) { + const { id } = use(params); + const [data, setData] = useState>['data']>(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getBiddingDetail(id) + .then(result => { + setData(result.data); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/bidding/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/[id]/page.tsx index 5c370885..51cec9a3 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/[id]/page.tsx @@ -1,18 +1,38 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; import { BiddingDetailForm, getBiddingDetail } from '@/components/business/construction/bidding'; interface BiddingDetailPageProps { params: Promise<{ id: string }>; } -export default async function BiddingDetailPage({ params }: BiddingDetailPageProps) { - const { id } = await params; - const result = await getBiddingDetail(id); +export default function BiddingDetailPage({ params }: BiddingDetailPageProps) { + const { id } = use(params); + const [data, setData] = useState>['data']>(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getBiddingDetail(id) + .then(result => { + setData(result.data); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx index 475bdc6e..311890aa 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/edit/page.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; import { EstimateDetailForm } from '@/components/business/construction/estimates'; import type { EstimateDetail } from '@/components/business/construction/estimates'; @@ -6,7 +9,7 @@ interface EstimateEditPageProps { } // 목업 데이터 - 추후 API 연동 -async function getEstimateDetail(id: string): Promise { +function getEstimateDetail(id: string): EstimateDetail { // TODO: 실제 API 연동 const mockData: EstimateDetail = { id, @@ -187,15 +190,30 @@ async function getEstimateDetail(id: string): Promise { return mockData; } -export default async function EstimateEditPage({ params }: EstimateEditPageProps) { - const { id } = await params; - const detail = await getEstimateDetail(id); +export default function EstimateEditPage({ params }: EstimateEditPageProps) { + const { id } = use(params); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const detail = getEstimateDetail(id); + setData(detail); + setIsLoading(false); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx index 9203692e..22385d31 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/estimates/[id]/page.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; import { EstimateDetailForm } from '@/components/business/construction/estimates'; import type { EstimateDetail } from '@/components/business/construction/estimates'; @@ -6,7 +9,7 @@ interface EstimateDetailPageProps { } // 목업 데이터 - 추후 API 연동 -async function getEstimateDetail(id: string): Promise { +function getEstimateDetail(id: string): EstimateDetail { // TODO: 실제 API 연동 const mockData: EstimateDetail = { id, @@ -187,15 +190,30 @@ async function getEstimateDetail(id: string): Promise { return mockData; } -export default async function EstimateDetailPage({ params }: EstimateDetailPageProps) { - const { id } = await params; - const detail = await getEstimateDetail(id); +export default function EstimateDetailPage({ params }: EstimateDetailPageProps) { + const { id } = use(params); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const detail = getEstimateDetail(id); + setData(detail); + setIsLoading(false); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); -} +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/edit/page.tsx index 2f64fccd..8f0bc930 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/edit/page.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import PartnerForm from '@/components/business/construction/partners/PartnerForm'; import { getPartner } from '@/components/business/construction/partners/actions'; @@ -5,15 +9,52 @@ interface PartnerEditPageProps { params: Promise<{ id: string }>; } -export default async function PartnerEditPage({ params }: PartnerEditPageProps) { - const { id } = await params; - const result = await getPartner(id); +export default function PartnerEditPage({ params }: PartnerEditPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getPartner(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('협력업체 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('협력업체 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/page.tsx index 72fd696c..17f41dd4 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/partners/[id]/page.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import PartnerForm from '@/components/business/construction/partners/PartnerForm'; import { getPartner } from '@/components/business/construction/partners/actions'; @@ -5,15 +9,52 @@ interface PartnerDetailPageProps { params: Promise<{ id: string }>; } -export default async function PartnerDetailPage({ params }: PartnerDetailPageProps) { - const { id } = await params; - const result = await getPartner(id); +export default function PartnerDetailPage({ params }: PartnerDetailPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getPartner(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('협력업체 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('협력업체 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } return ( ); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/edit/page.tsx index aae936ed..19cb1f9b 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/edit/page.tsx @@ -1,18 +1,59 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings'; interface SiteBriefingEditPageProps { params: Promise<{ id: string }>; } -export default async function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) { - const { id } = await params; - const result = await getSiteBriefing(id); +export default function SiteBriefingEditPage({ params }: SiteBriefingEditPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getSiteBriefing(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('현장 설명회 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('현장 설명회 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/page.tsx index dbcefdb5..2c8524c0 100644 --- a/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/bidding/site-briefings/[id]/page.tsx @@ -1,18 +1,59 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { SiteBriefingForm, getSiteBriefing } from '@/components/business/construction/site-briefings'; interface SiteBriefingDetailPageProps { params: Promise<{ id: string }>; } -export default async function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) { - const { id } = await params; - const result = await getSiteBriefing(id); +export default function SiteBriefingDetailPage({ params }: SiteBriefingDetailPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getSiteBriefing(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('현장 설명회 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('현장 설명회 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } return ( ); } diff --git a/src/app/[locale]/(protected)/construction/project/contract/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/contract/[id]/edit/page.tsx index a0e32370..7a1a6b89 100644 --- a/src/app/[locale]/(protected)/construction/project/contract/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/contract/[id]/edit/page.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm'; import { getContractDetail } from '@/components/business/construction/contract'; @@ -5,15 +9,52 @@ interface ContractEditPageProps { params: Promise<{ id: string }>; } -export default async function ContractEditPage({ params }: ContractEditPageProps) { - const { id } = await params; - const result = await getContractDetail(id); +export default function ContractEditPage({ params }: ContractEditPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getContractDetail(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('계약 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('계약 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/contract/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/contract/[id]/page.tsx index b4ad89ca..654b0be4 100644 --- a/src/app/[locale]/(protected)/construction/project/contract/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/contract/[id]/page.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import ContractDetailForm from '@/components/business/construction/contract/ContractDetailForm'; import { getContractDetail } from '@/components/business/construction/contract'; @@ -5,15 +9,52 @@ interface ContractDetailPageProps { params: Promise<{ id: string }>; } -export default async function ContractDetailPage({ params }: ContractDetailPageProps) { - const { id } = await params; - const result = await getContractDetail(id); +export default function ContractDetailPage({ params }: ContractDetailPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + getContractDetail(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('계약 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('계약 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } return ( ); -} +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/edit/page.tsx b/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/edit/page.tsx index d472388a..cbb92c74 100644 --- a/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/edit/page.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report'; interface HandoverReportEditPageProps { @@ -7,17 +11,52 @@ interface HandoverReportEditPageProps { }>; } -export default async function HandoverReportEditPage({ params }: HandoverReportEditPageProps) { - const { id } = await params; +export default function HandoverReportEditPage({ params }: HandoverReportEditPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - // 서버에서 상세 데이터 조회 - const result = await getHandoverReportDetail(id); + useEffect(() => { + getHandoverReportDetail(id) + .then(result => { + if (result.data) { + setData(result.data); + } else { + setError('인수인계서 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('인수인계서 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } return ( ); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/page.tsx b/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/page.tsx index 8292265d..03ad8289 100644 --- a/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/page.tsx +++ b/src/app/[locale]/(protected)/construction/project/contract/handover-report/[id]/page.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/construction/handover-report'; interface HandoverReportDetailPageProps { @@ -7,17 +11,52 @@ interface HandoverReportDetailPageProps { }>; } -export default async function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) { - const { id } = await params; +export default function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - // 서버에서 상세 데이터 조회 - const result = await getHandoverReportDetail(id); + useEffect(() => { + getHandoverReportDetail(id) + .then(result => { + if (result.data) { + setData(result.data); + } else { + setError('인수인계서 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('인수인계서 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error) { + return ( +
+
{error}
+ +
+ ); + } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/dev/construction-test-urls/actions.ts b/src/app/[locale]/(protected)/dev/construction-test-urls/actions.ts new file mode 100644 index 00000000..ae412ee4 --- /dev/null +++ b/src/app/[locale]/(protected)/dev/construction-test-urls/actions.ts @@ -0,0 +1,141 @@ +'use server'; + +import { promises as fs } from 'fs'; +import path from 'path'; +import type { UrlCategory, UrlItem } from './ConstructionTestUrlsClient'; + +// 아이콘 매핑 +const iconMap: Record = { + '기본': '🏠', + '시스템': '💻', + '대시보드': '📊', +}; + +function getIcon(title: string): string { + for (const [key, icon] of Object.entries(iconMap)) { + if (title.includes(key)) return icon; + } + return '📄'; +} + +function parseTableRow(line: string): UrlItem | null { + // | 페이지 | URL | 상태 | 형식 파싱 + const parts = line.split('|').map(p => p.trim()).filter(p => p); + + if (parts.length < 2) return null; + if (parts[0] === '페이지' || parts[0].startsWith('---')) return null; + + const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거 + const url = parts[1].replace(/`/g, ''); // backtick 제거 + const status = parts[2] || undefined; + + // URL이 /ko로 시작하는지 확인 + if (!url.startsWith('/ko')) return null; + + return { name, url, status }; +} + +function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } { + const lines = content.split('\n'); + const categories: UrlCategory[] = []; + let currentCategory: UrlCategory | null = null; + let currentSubCategory: { title: string; items: UrlItem[] } | null = null; + let lastUpdated = 'N/A'; + + // Last Updated 추출 + const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/); + if (updateMatch) { + lastUpdated = updateMatch[1]; + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // ## 카테고리 (메인 섹션) + if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) { + // 이전 카테고리 저장 + if (currentCategory) { + if (currentSubCategory) { + currentCategory.subCategories = currentCategory.subCategories || []; + currentCategory.subCategories.push(currentSubCategory); + currentSubCategory = null; + } + categories.push(currentCategory); + } + + const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim(); + currentCategory = { + title, + icon: getIcon(title), + items: [], + subCategories: [], + }; + currentSubCategory = null; + } + + // ### 서브 카테고리 + else if (line.startsWith('### ') && currentCategory) { + // 이전 서브카테고리 저장 + if (currentSubCategory) { + currentCategory.subCategories = currentCategory.subCategories || []; + currentCategory.subCategories.push(currentSubCategory); + } + + const subTitle = line.replace('### ', '').trim(); + // "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로 + if (subTitle === '메인 페이지') { + currentSubCategory = null; + } else { + currentSubCategory = { + title: subTitle, + items: [], + }; + } + } + + // 테이블 행 파싱 + else if (line.startsWith('|') && currentCategory) { + const item = parseTableRow(line); + if (item) { + if (currentSubCategory) { + currentSubCategory.items.push(item); + } else { + currentCategory.items.push(item); + } + } + } + } + + // 마지막 카테고리 저장 + if (currentCategory) { + if (currentSubCategory) { + currentCategory.subCategories = currentCategory.subCategories || []; + currentCategory.subCategories.push(currentSubCategory); + } + categories.push(currentCategory); + } + + // 빈 서브카테고리 제거 + categories.forEach(cat => { + cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0); + }); + + return { categories, lastUpdated }; +} + +export async function getConstructionTestUrlsData(): Promise<{ categories: UrlCategory[]; lastUpdated: string }> { + // md 파일 경로 + const mdFilePath = path.join( + process.cwd(), + 'claudedocs', + '[REF] construction-pages-test-urls.md' + ); + + try { + const fileContent = await fs.readFile(mdFilePath, 'utf-8'); + return parseMdFile(fileContent); + } catch (error) { + console.error('Failed to read md file:', error); + return { categories: [], lastUpdated: 'N/A' }; + } +} diff --git a/src/app/[locale]/(protected)/dev/construction-test-urls/page.tsx b/src/app/[locale]/(protected)/dev/construction-test-urls/page.tsx index 370cc150..35942381 100644 --- a/src/app/[locale]/(protected)/dev/construction-test-urls/page.tsx +++ b/src/app/[locale]/(protected)/dev/construction-test-urls/page.tsx @@ -1,152 +1,30 @@ +'use client'; -import { promises as fs } from 'fs'; -import path from 'path'; -import ConstructionTestUrlsClient, { UrlCategory, UrlItem } from './ConstructionTestUrlsClient'; +import { useEffect, useState } from 'react'; +import ConstructionTestUrlsClient, { UrlCategory } from './ConstructionTestUrlsClient'; +import { getConstructionTestUrlsData } from './actions'; -// 아이콘 매핑 -const iconMap: Record = { - '기본': '🏠', - '시스템': '💻', - '대시보드': '📊', -}; +export default function TestUrlsPage() { + const [urlData, setUrlData] = useState([]); + const [lastUpdated, setLastUpdated] = useState('N/A'); + const [isLoading, setIsLoading] = useState(true); -function getIcon(title: string): string { - for (const [key, icon] of Object.entries(iconMap)) { - if (title.includes(key)) return icon; - } - return '📄'; -} + useEffect(() => { + getConstructionTestUrlsData() + .then(result => { + setUrlData(result.categories); + setLastUpdated(result.lastUpdated); + }) + .finally(() => setIsLoading(false)); + }, []); -function parseTableRow(line: string): UrlItem | null { - // | 페이지 | URL | 상태 | 형식 파싱 - const parts = line.split('|').map(p => p.trim()).filter(p => p); - - if (parts.length < 2) return null; - if (parts[0] === '페이지' || parts[0].startsWith('---')) return null; - - const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거 - const url = parts[1].replace(/`/g, ''); // backtick 제거 - const status = parts[2] || undefined; - - // URL이 /ko로 시작하는지 확인 - if (!url.startsWith('/ko')) return null; - - return { name, url, status }; -} - -function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } { - const lines = content.split('\n'); - const categories: UrlCategory[] = []; - let currentCategory: UrlCategory | null = null; - let currentSubCategory: { title: string; items: UrlItem[] } | null = null; - let lastUpdated = 'N/A'; - - // Last Updated 추출 - const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/); - if (updateMatch) { - lastUpdated = updateMatch[1]; - } - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - // ## 카테고리 (메인 섹션) - if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) { - // 이전 카테고리 저장 - if (currentCategory) { - if (currentSubCategory) { - currentCategory.subCategories = currentCategory.subCategories || []; - currentCategory.subCategories.push(currentSubCategory); - currentSubCategory = null; - } - categories.push(currentCategory); - } - - const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim(); - currentCategory = { - title, - icon: getIcon(title), - items: [], - subCategories: [], - }; - currentSubCategory = null; - } - - // ### 서브 카테고리 - else if (line.startsWith('### ') && currentCategory) { - // 이전 서브카테고리 저장 - if (currentSubCategory) { - currentCategory.subCategories = currentCategory.subCategories || []; - currentCategory.subCategories.push(currentSubCategory); - } - - const subTitle = line.replace('### ', '').trim(); - // "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로 - if (subTitle === '메인 페이지') { - currentSubCategory = null; - } else { - currentSubCategory = { - title: subTitle, - items: [], - }; - } - } - - // 테이블 행 파싱 - else if (line.startsWith('|') && currentCategory) { - const item = parseTableRow(line); - if (item) { - if (currentSubCategory) { - currentSubCategory.items.push(item); - } else { - currentCategory.items.push(item); - } - } - } - } - - // 마지막 카테고리 저장 - if (currentCategory) { - if (currentSubCategory) { - currentCategory.subCategories = currentCategory.subCategories || []; - currentCategory.subCategories.push(currentSubCategory); - } - categories.push(currentCategory); - } - - // 빈 서브카테고리 제거 - categories.forEach(cat => { - cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0); - }); - - return { categories, lastUpdated }; -} - -export default async function TestUrlsPage() { - // md 파일 경로 - const mdFilePath = path.join( - process.cwd(), - 'claudedocs', - '[REF] construction-pages-test-urls.md' + if (isLoading) { + return ( +
+
로딩 중...
+
); + } - let urlData: UrlCategory[] = []; - let lastUpdated = 'N/A'; - - try { - const fileContent = await fs.readFile(mdFilePath, 'utf-8'); - const parsed = parseMdFile(fileContent); - urlData = parsed.categories; - lastUpdated = parsed.lastUpdated; - } catch (error) { - console.error('Failed to read md file:', error); - // 파일 읽기 실패 시 빈 데이터 - urlData = []; - } - - return ; -} - -// 캐싱 비활성화 - 항상 최신 md 파일 읽기 -export const dynamic = 'force-dynamic'; -export const revalidate = 0; + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/dev/test-urls/actions.ts b/src/app/[locale]/(protected)/dev/test-urls/actions.ts new file mode 100644 index 00000000..54f309c4 --- /dev/null +++ b/src/app/[locale]/(protected)/dev/test-urls/actions.ts @@ -0,0 +1,157 @@ +'use server'; + +import { promises as fs } from 'fs'; +import path from 'path'; +import type { UrlCategory, UrlItem } from './TestUrlsClient'; + +// 아이콘 매핑 +const iconMap: Record = { + '기본': '🏠', + '인사관리': '👥', + 'HR': '👥', + '판매관리': '💰', + 'Sales': '💰', + '기준정보관리': '📦', + 'Master Data': '📦', + '생산관리': '🏭', + 'Production': '🏭', + '설정': '⚙️', + 'Settings': '⚙️', + '전자결재': '📝', + 'Approval': '📝', + '회계관리': '💵', + 'Accounting': '💵', + '게시판': '📋', + 'Board': '📋', + '보고서': '📊', + 'Reports': '📊', +}; + +function getIcon(title: string): string { + for (const [key, icon] of Object.entries(iconMap)) { + if (title.includes(key)) return icon; + } + return '📄'; +} + +function parseTableRow(line: string): UrlItem | null { + // | 페이지 | URL | 상태 | 형식 파싱 + const parts = line.split('|').map(p => p.trim()).filter(p => p); + + if (parts.length < 2) return null; + if (parts[0] === '페이지' || parts[0].startsWith('---')) return null; + + const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거 + const url = parts[1].replace(/`/g, ''); // backtick 제거 + const status = parts[2] || undefined; + + // URL이 /ko로 시작하는지 확인 + if (!url.startsWith('/ko')) return null; + + return { name, url, status }; +} + +function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } { + const lines = content.split('\n'); + const categories: UrlCategory[] = []; + let currentCategory: UrlCategory | null = null; + let currentSubCategory: { title: string; items: UrlItem[] } | null = null; + let lastUpdated = 'N/A'; + + // Last Updated 추출 + const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/); + if (updateMatch) { + lastUpdated = updateMatch[1]; + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // ## 카테고리 (메인 섹션) + if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) { + // 이전 카테고리 저장 + if (currentCategory) { + if (currentSubCategory) { + currentCategory.subCategories = currentCategory.subCategories || []; + currentCategory.subCategories.push(currentSubCategory); + currentSubCategory = null; + } + categories.push(currentCategory); + } + + const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim(); + currentCategory = { + title, + icon: getIcon(title), + items: [], + subCategories: [], + }; + currentSubCategory = null; + } + + // ### 서브 카테고리 + else if (line.startsWith('### ') && currentCategory) { + // 이전 서브카테고리 저장 + if (currentSubCategory) { + currentCategory.subCategories = currentCategory.subCategories || []; + currentCategory.subCategories.push(currentSubCategory); + } + + const subTitle = line.replace('### ', '').trim(); + // "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로 + if (subTitle === '메인 페이지') { + currentSubCategory = null; + } else { + currentSubCategory = { + title: subTitle, + items: [], + }; + } + } + + // 테이블 행 파싱 + else if (line.startsWith('|') && currentCategory) { + const item = parseTableRow(line); + if (item) { + if (currentSubCategory) { + currentSubCategory.items.push(item); + } else { + currentCategory.items.push(item); + } + } + } + } + + // 마지막 카테고리 저장 + if (currentCategory) { + if (currentSubCategory) { + currentCategory.subCategories = currentCategory.subCategories || []; + currentCategory.subCategories.push(currentSubCategory); + } + categories.push(currentCategory); + } + + // 빈 서브카테고리 제거 + categories.forEach(cat => { + cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0); + }); + + return { categories, lastUpdated }; +} + +export async function getTestUrlsData(): Promise<{ categories: UrlCategory[]; lastUpdated: string }> { + // md 파일 경로 + const mdFilePath = path.join( + process.cwd(), + 'claudedocs', + '[REF] all-pages-test-urls.md' + ); + + try { + const fileContent = await fs.readFile(mdFilePath, 'utf-8'); + return parseMdFile(fileContent); + } catch (error) { + console.error('Failed to read md file:', error); + return { categories: [], lastUpdated: 'N/A' }; + } +} diff --git a/src/app/[locale]/(protected)/dev/test-urls/page.tsx b/src/app/[locale]/(protected)/dev/test-urls/page.tsx index fbfcc7f8..73402243 100644 --- a/src/app/[locale]/(protected)/dev/test-urls/page.tsx +++ b/src/app/[locale]/(protected)/dev/test-urls/page.tsx @@ -1,167 +1,30 @@ -import { promises as fs } from 'fs'; -import path from 'path'; -import TestUrlsClient, { UrlCategory, UrlItem } from './TestUrlsClient'; +'use client'; -// 아이콘 매핑 -const iconMap: Record = { - '기본': '🏠', - '인사관리': '👥', - 'HR': '👥', - '판매관리': '💰', - 'Sales': '💰', - '기준정보관리': '📦', - 'Master Data': '📦', - '생산관리': '🏭', - 'Production': '🏭', - '설정': '⚙️', - 'Settings': '⚙️', - '전자결재': '📝', - 'Approval': '📝', - '회계관리': '💵', - 'Accounting': '💵', - '게시판': '📋', - 'Board': '📋', - '보고서': '📊', - 'Reports': '📊', -}; +import { useEffect, useState } from 'react'; +import TestUrlsClient, { UrlCategory } from './TestUrlsClient'; +import { getTestUrlsData } from './actions'; -function getIcon(title: string): string { - for (const [key, icon] of Object.entries(iconMap)) { - if (title.includes(key)) return icon; - } - return '📄'; -} +export default function TestUrlsPage() { + const [urlData, setUrlData] = useState([]); + const [lastUpdated, setLastUpdated] = useState('N/A'); + const [isLoading, setIsLoading] = useState(true); -function parseTableRow(line: string): UrlItem | null { - // | 페이지 | URL | 상태 | 형식 파싱 - const parts = line.split('|').map(p => p.trim()).filter(p => p); + useEffect(() => { + getTestUrlsData() + .then(result => { + setUrlData(result.categories); + setLastUpdated(result.lastUpdated); + }) + .finally(() => setIsLoading(false)); + }, []); - if (parts.length < 2) return null; - if (parts[0] === '페이지' || parts[0].startsWith('---')) return null; - - const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거 - const url = parts[1].replace(/`/g, ''); // backtick 제거 - const status = parts[2] || undefined; - - // URL이 /ko로 시작하는지 확인 - if (!url.startsWith('/ko')) return null; - - return { name, url, status }; -} - -function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated: string } { - const lines = content.split('\n'); - const categories: UrlCategory[] = []; - let currentCategory: UrlCategory | null = null; - let currentSubCategory: { title: string; items: UrlItem[] } | null = null; - let lastUpdated = 'N/A'; - - // Last Updated 추출 - const updateMatch = content.match(/Last Updated:\s*(\d{4}-\d{2}-\d{2})/); - if (updateMatch) { - lastUpdated = updateMatch[1]; - } - - for (let i = 0; i < lines.length; i++) { - const line = lines[i].trim(); - - // ## 카테고리 (메인 섹션) - if (line.startsWith('## ') && !line.includes('클릭 가능한') && !line.includes('전체 URL') && !line.includes('백엔드 메뉴')) { - // 이전 카테고리 저장 - if (currentCategory) { - if (currentSubCategory) { - currentCategory.subCategories = currentCategory.subCategories || []; - currentCategory.subCategories.push(currentSubCategory); - currentSubCategory = null; - } - categories.push(currentCategory); - } - - const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim(); - currentCategory = { - title, - icon: getIcon(title), - items: [], - subCategories: [], - }; - currentSubCategory = null; - } - - // ### 서브 카테고리 - else if (line.startsWith('### ') && currentCategory) { - // 이전 서브카테고리 저장 - if (currentSubCategory) { - currentCategory.subCategories = currentCategory.subCategories || []; - currentCategory.subCategories.push(currentSubCategory); - } - - const subTitle = line.replace('### ', '').trim(); - // "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로 - if (subTitle === '메인 페이지') { - currentSubCategory = null; - } else { - currentSubCategory = { - title: subTitle, - items: [], - }; - } - } - - // 테이블 행 파싱 - else if (line.startsWith('|') && currentCategory) { - const item = parseTableRow(line); - if (item) { - if (currentSubCategory) { - currentSubCategory.items.push(item); - } else { - currentCategory.items.push(item); - } - } - } - } - - // 마지막 카테고리 저장 - if (currentCategory) { - if (currentSubCategory) { - currentCategory.subCategories = currentCategory.subCategories || []; - currentCategory.subCategories.push(currentSubCategory); - } - categories.push(currentCategory); - } - - // 빈 서브카테고리 제거 - categories.forEach(cat => { - cat.subCategories = cat.subCategories?.filter(sub => sub.items.length > 0); - }); - - return { categories, lastUpdated }; -} - -export default async function TestUrlsPage() { - // md 파일 경로 - const mdFilePath = path.join( - process.cwd(), - 'claudedocs', - '[REF] all-pages-test-urls.md' - ); - - let urlData: UrlCategory[] = []; - let lastUpdated = 'N/A'; - - try { - const fileContent = await fs.readFile(mdFilePath, 'utf-8'); - const parsed = parseMdFile(fileContent); - urlData = parsed.categories; - lastUpdated = parsed.lastUpdated; - } catch (error) { - console.error('Failed to read md file:', error); - // 파일 읽기 실패 시 빈 데이터 - urlData = []; + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); } return ; -} - -// 캐싱 비활성화 - 항상 최신 md 파일 읽기 -export const dynamic = 'force-dynamic'; -export const revalidate = 0; \ No newline at end of file +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/master-data/process-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/master-data/process-management/[id]/edit/page.tsx index e23b289d..24457f13 100644 --- a/src/app/[locale]/(protected)/master-data/process-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/master-data/process-management/[id]/edit/page.tsx @@ -1,42 +1,62 @@ +'use client'; + /** - * 공정 수정 페이지 + * 공정 수정 페이지 (Client Component) */ -import { notFound } from 'next/navigation'; +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { ProcessForm } from '@/components/process-management'; import { getProcessById } from '@/components/process-management/actions'; +import type { Process } from '@/components/process-management/types'; -export default async function EditProcessPage({ +export default function EditProcessPage({ params, }: { params: Promise<{ id: string }>; }) { - const { id } = await params; - const result = await getProcessById(id); + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - if (!result.success || !result.data) { - notFound(); + useEffect(() => { + getProcessById(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('공정 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('공정 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
공정 정보를 불러오는 중...
+
+ ); } - return ; -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const result = await getProcessById(id); - - if (!result.success || !result.data) { - return { - title: '공정을 찾을 수 없습니다', - }; + if (error || !data) { + return ( +
+
{error || '공정을 찾을 수 없습니다.'}
+ +
+ ); } - return { - title: `${result.data.processName} - 공정 수정`, - description: `${result.data.processCode} 공정 수정`, - }; -} + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx b/src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx index 42e983ae..8f5f70f9 100644 --- a/src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/master-data/process-management/[id]/page.tsx @@ -1,48 +1,62 @@ +'use client'; + /** - * 공정 상세 페이지 + * 공정 상세 페이지 (Client Component) */ -import { Suspense } from 'react'; -import { notFound } from 'next/navigation'; +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { ProcessDetail } from '@/components/process-management'; -import { ContentLoadingSpinner } from '@/components/ui/loading-spinner'; import { getProcessById } from '@/components/process-management/actions'; +import type { Process } from '@/components/process-management/types'; -export default async function ProcessDetailPage({ +export default function ProcessDetailPage({ params, }: { params: Promise<{ id: string }>; }) { - const { id } = await params; - const result = await getProcessById(id); + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - if (!result.success || !result.data) { - notFound(); + useEffect(() => { + getProcessById(id) + .then(result => { + if (result.success && result.data) { + setData(result.data); + } else { + setError('공정 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('공정 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); + + if (isLoading) { + return ( +
+
공정 정보를 불러오는 중...
+
+ ); } - return ( - }> - - - ); -} - -export async function generateMetadata({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const result = await getProcessById(id); - - if (!result.success || !result.data) { - return { - title: '공정을 찾을 수 없습니다', - }; + if (error || !data) { + return ( +
+
{error || '공정을 찾을 수 없습니다.'}
+ +
+ ); } - return { - title: `${result.data.processName} - 공정 상세`, - description: `${result.data.processCode} 공정 정보`, - }; -} + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/material/receiving-management/[id]/page.tsx b/src/app/[locale]/(protected)/material/receiving-management/[id]/page.tsx index 5bbc4740..0c65a80b 100644 --- a/src/app/[locale]/(protected)/material/receiving-management/[id]/page.tsx +++ b/src/app/[locale]/(protected)/material/receiving-management/[id]/page.tsx @@ -1,10 +1,13 @@ +'use client'; + +import { use } from 'react'; import { ReceivingDetail } from '@/components/material/ReceivingManagement'; interface Props { params: Promise<{ id: string }>; } -export default async function ReceivingDetailPage({ params }: Props) { - const { id } = await params; +export default function ReceivingDetailPage({ params }: Props) { + const { id } = use(params); return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/material/stock-status/[id]/page.tsx b/src/app/[locale]/(protected)/material/stock-status/[id]/page.tsx index bf424c2e..cec31a7f 100644 --- a/src/app/[locale]/(protected)/material/stock-status/[id]/page.tsx +++ b/src/app/[locale]/(protected)/material/stock-status/[id]/page.tsx @@ -1,3 +1,6 @@ +'use client'; + +import { use } from 'react'; import { StockStatusDetail } from '@/components/material/StockStatus'; interface StockStatusDetailPageProps { @@ -6,7 +9,7 @@ interface StockStatusDetailPageProps { }>; } -export default async function StockStatusDetailPage({ params }: StockStatusDetailPageProps) { - const { id } = await params; +export default function StockStatusDetailPage({ params }: StockStatusDetailPageProps) { + const { id } = use(params); return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/outbound/shipments/[id]/edit/page.tsx b/src/app/[locale]/(protected)/outbound/shipments/[id]/edit/page.tsx index 345c614c..897df92e 100644 --- a/src/app/[locale]/(protected)/outbound/shipments/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/outbound/shipments/[id]/edit/page.tsx @@ -1,15 +1,18 @@ +'use client'; + /** - * 출하관리 - 수정 페이지 + * 출하관리 - 수정 페이지 (Client Component) * URL: /outbound/shipments/[id]/edit */ +import { use } from 'react'; import { ShipmentEdit } from '@/components/outbound/ShipmentManagement'; interface ShipmentEditPageProps { params: Promise<{ id: string }>; } -export default async function ShipmentEditPage({ params }: ShipmentEditPageProps) { - const { id } = await params; +export default function ShipmentEditPage({ params }: ShipmentEditPageProps) { + const { id } = use(params); return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/outbound/shipments/[id]/page.tsx b/src/app/[locale]/(protected)/outbound/shipments/[id]/page.tsx index 2793fef4..c6961b5b 100644 --- a/src/app/[locale]/(protected)/outbound/shipments/[id]/page.tsx +++ b/src/app/[locale]/(protected)/outbound/shipments/[id]/page.tsx @@ -1,15 +1,18 @@ +'use client'; + /** - * 출하관리 - 상세 페이지 + * 출하관리 - 상세 페이지 (Client Component) * URL: /outbound/shipments/[id] */ +import { use } from 'react'; import { ShipmentDetail } from '@/components/outbound/ShipmentManagement'; interface ShipmentDetailPageProps { params: Promise<{ id: string }>; } -export default async function ShipmentDetailPage({ params }: ShipmentDetailPageProps) { - const { id } = await params; +export default function ShipmentDetailPage({ params }: ShipmentDetailPageProps) { + const { id } = use(params); return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/payment-history/page.tsx b/src/app/[locale]/(protected)/payment-history/page.tsx index 5bb14a33..eef26c0e 100644 --- a/src/app/[locale]/(protected)/payment-history/page.tsx +++ b/src/app/[locale]/(protected)/payment-history/page.tsx @@ -1,13 +1,35 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { PaymentHistoryManagement } from '@/components/settings/PaymentHistoryManagement'; import { getPayments } from '@/components/settings/PaymentHistoryManagement/actions'; -export default async function PaymentHistoryPage() { - const result = await getPayments({ perPage: 100 }); +export default function PaymentHistoryPage() { + const [data, setData] = useState>['data']>(undefined); + const [pagination, setPagination] = useState>['pagination']>(undefined); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getPayments({ perPage: 100 }) + .then(result => { + setData(result.data); + setPagination(result.pagination); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx b/src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx index 74929ac1..a8bd08db 100644 --- a/src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx +++ b/src/app/[locale]/(protected)/production/screen-production/[id]/page.tsx @@ -1,9 +1,11 @@ +'use client'; + /** - * 품목 상세 조회 페이지 + * 품목 상세 조회 페이지 (Client Component) */ -import { Suspense } from 'react'; -import { notFound } from 'next/navigation'; +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import ItemDetailClient from '@/components/items/ItemDetailClient'; import type { ItemMaster } from '@/types/item'; @@ -134,62 +136,53 @@ const mockItems: ItemMaster[] = [ }, ]; -/** - * 품목 조회 함수 - * TODO: API 연동 시 fetchItemByCode()로 교체 - */ -async function getItemByCode(itemCode: string): Promise { - // API 연동 전 mock 데이터 반환 - // const item = await fetchItemByCode(itemCode); - const item = mockItems.find( - (item) => item.itemCode === decodeURIComponent(itemCode) - ); - return item || null; -} - /** * 품목 상세 페이지 */ -export default async function ItemDetailPage({ +export default function ItemDetailPage({ params, }: { params: Promise<{ id: string }>; }) { - const { id } = await params; - const item = await getItemByCode(id); + const { id } = use(params); + const router = useRouter(); + const [item, setItem] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // API 연동 전 mock 데이터 사용 + const foundItem = mockItems.find( + (item) => item.itemCode === decodeURIComponent(id) + ); + setItem(foundItem || null); + setIsLoading(false); + }, [id]); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } if (!item) { - notFound(); + return ( +
+
품목을 찾을 수 없습니다.
+ +
+ ); } return (
- 로딩 중...
}> - - + ); -} - -/** - * 메타데이터 설정 - */ -export async function generateMetadata({ - params, -}: { - params: Promise<{ id: string }>; -}) { - const { id } = await params; - const item = await getItemByCode(id); - - if (!item) { - return { - title: '품목을 찾을 수 없습니다', - }; - } - - return { - title: `${item.itemName} - 품목 상세`, - description: `${item.itemCode} 품목 정보`, - }; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/production/screen-production/page.tsx b/src/app/[locale]/(protected)/production/screen-production/page.tsx index 5dcf0340..e9c54b24 100644 --- a/src/app/[locale]/(protected)/production/screen-production/page.tsx +++ b/src/app/[locale]/(protected)/production/screen-production/page.tsx @@ -1,160 +1,16 @@ +'use client'; + /** - * 품목 목록 페이지 (Server Component) + * 품목 목록 페이지 (Client Component) * * Next.js 15 App Router - * 서버에서 데이터 fetching 후 Client Component로 전달 */ -import { Suspense } from 'react'; import ItemListClient from '@/components/items/ItemListClient'; -import type { ItemMaster } from '@/types/item'; - -// Mock 데이터 (API 연동 전 임시) -const mockItems: ItemMaster[] = [ - { - id: '1', - itemCode: 'KD-FG-001', - itemName: '스크린 제품 A', - itemType: 'FG', - unit: 'EA', - specification: '2000x2000', - isActive: true, - category1: '본체부품', - category2: '가이드시스템', - salesPrice: 150000, - purchasePrice: 100000, - productCategory: 'SCREEN', - lotAbbreviation: 'KD', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '2', - itemCode: 'KD-PT-001', - itemName: '가이드레일(벽면형)', - itemType: 'PT', - unit: 'EA', - specification: '2438mm', - isActive: true, - category1: '본체부품', - category2: '가이드시스템', - category3: '가이드레일', - salesPrice: 50000, - purchasePrice: 35000, - partType: 'ASSEMBLY', - partUsage: 'GUIDE_RAIL', - installationType: '벽면형', - assemblyType: 'M', - assemblyLength: '2438', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '3', - itemCode: 'KD-PT-002', - itemName: '절곡품 샘플', - itemType: 'PT', - unit: 'EA', - specification: 'EGI 1.55T', - isActive: true, - partType: 'BENDING', - material: 'EGI 1.55T', - length: '2000', - salesPrice: 30000, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '4', - itemCode: 'KD-RM-001', - itemName: 'SPHC-SD', - itemType: 'RM', - unit: 'KG', - specification: '1.6T x 1219 x 2438', - isActive: true, - category1: '철강재', - purchasePrice: 1500, - material: 'SPHC-SD', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '5', - itemCode: 'KD-SM-001', - itemName: '볼트 M6x20', - itemType: 'SM', - unit: 'EA', - specification: 'M6x20', - isActive: true, - category1: '구조재/부속품', - category2: '볼트/너트', - purchasePrice: 50, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '6', - itemCode: 'KD-CS-001', - itemName: '절삭유', - itemType: 'CS', - unit: 'L', - specification: '20L', - isActive: true, - purchasePrice: 30000, - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-10T00:00:00Z', - }, - { - id: '7', - itemCode: 'KD-FG-002', - itemName: '철재 제품 B', - itemType: 'FG', - unit: 'SET', - specification: '3000x2500', - isActive: false, - category1: '본체부품', - salesPrice: 200000, - productCategory: 'STEEL', - lotAbbreviation: 'KD', - currentRevision: 0, - isFinal: false, - createdAt: '2025-01-09T00:00:00Z', - }, -]; - -/** - * 품목 목록 조회 함수 - * TODO: API 연동 시 fetchItems()로 교체 - */ -async function getItems(): Promise { - // API 연동 전 mock 데이터 반환 - // const items = await fetchItems(); - return mockItems; -} /** * 품목 목록 페이지 */ -export default async function ItemsPage() { - const items = await getItems(); - - return ( - 로딩 중...}> - - - ); -} - -/** - * 메타데이터 설정 - */ -export const metadata = { - title: '품목 관리', - description: '품목 목록 조회 및 관리', -}; \ No newline at end of file +export default function ItemsPage() { + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/production/work-orders/[id]/page.tsx b/src/app/[locale]/(protected)/production/work-orders/[id]/page.tsx index d47eb9c3..70fd23f8 100644 --- a/src/app/[locale]/(protected)/production/work-orders/[id]/page.tsx +++ b/src/app/[locale]/(protected)/production/work-orders/[id]/page.tsx @@ -1,8 +1,11 @@ +'use client'; + /** - * 작업지시 상세 페이지 + * 작업지시 상세 페이지 (Client Component) * URL: /production/work-orders/[id] */ +import { use } from 'react'; import { WorkOrderDetail } from '@/components/production/WorkOrders'; interface PageProps { @@ -11,7 +14,7 @@ interface PageProps { }>; } -export default async function WorkOrderDetailPage({ params }: PageProps) { - const { id } = await params; +export default function WorkOrderDetailPage({ params }: PageProps) { + const { id } = use(params); return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/quality/inspections/[id]/page.tsx b/src/app/[locale]/(protected)/quality/inspections/[id]/page.tsx index cd446776..03d9a9d4 100644 --- a/src/app/[locale]/(protected)/quality/inspections/[id]/page.tsx +++ b/src/app/[locale]/(protected)/quality/inspections/[id]/page.tsx @@ -1,16 +1,19 @@ +'use client'; + /** - * 검사 상세/수정 페이지 + * 검사 상세/수정 페이지 (Client Component) * URL: /quality/inspections/[id] * 수정 모드: /quality/inspections/[id]?mode=edit */ +import { use } from 'react'; import { InspectionDetail } from '@/components/quality/InspectionManagement'; interface Props { params: Promise<{ id: string }>; } -export default async function InspectionDetailPage({ params }: Props) { - const { id } = await params; +export default function InspectionDetailPage({ params }: Props) { + const { id } = use(params); return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/pricing-management/[id]/edit/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/[id]/edit/page.tsx index e43b8425..90d2b4cd 100644 --- a/src/app/[locale]/(protected)/sales/pricing-management/[id]/edit/page.tsx +++ b/src/app/[locale]/(protected)/sales/pricing-management/[id]/edit/page.tsx @@ -1,10 +1,14 @@ +'use client'; + /** - * 단가 수정 페이지 + * 단가 수정 페이지 (Client Component) * * 경로: /sales/pricing-management/[id]/edit * API: GET /api/v1/pricing/{id}, PUT /api/v1/pricing/{id} */ +import { use, useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { PricingFormClient } from '@/components/pricing'; import { getPricingById, updatePricing, finalizePricing } from '@/components/pricing/actions'; import type { PricingData } from '@/components/pricing'; @@ -15,57 +19,79 @@ interface EditPricingPageProps { }>; } -export default async function EditPricingPage({ params }: EditPricingPageProps) { - const { id } = await params; +export default function EditPricingPage({ params }: EditPricingPageProps) { + const { id } = use(params); + const router = useRouter(); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - // 기존 단가 데이터 조회 - const pricingData = await getPricingById(id); + useEffect(() => { + getPricingById(id) + .then(result => { + if (result) { + setData(result); + } else { + setError('단가 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('단가 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [id]); - if (!pricingData) { + // 단가 수정 핸들러 + const handleSave = async (formData: PricingData, isRevision?: boolean, revisionReason?: string) => { + const result = await updatePricing(id, formData, revisionReason); + if (!result.success) { + throw new Error(result.error || '단가 수정에 실패했습니다.'); + } + console.log('[EditPricingPage] 단가 수정 성공:', result.data, { isRevision, revisionReason }); + }; + + // 단가 확정 핸들러 + const handleFinalize = async (priceId: string) => { + const result = await finalizePricing(priceId); + if (!result.success) { + throw new Error(result.error || '단가 확정에 실패했습니다.'); + } + console.log('[EditPricingPage] 단가 확정 성공:', result.data); + }; + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + if (error || !data) { return (
-

단가 정보를 찾을 수 없습니다

-

+

{error || '단가 정보를 찾을 수 없습니다'}

+

올바른 단가 정보로 다시 시도해주세요.

+
); } - // 서버 액션: 단가 수정 - async function handleSave(data: PricingData, isRevision?: boolean, revisionReason?: string) { - 'use server'; - - const result = await updatePricing(id, data, revisionReason); - - if (!result.success) { - throw new Error(result.error || '단가 수정에 실패했습니다.'); - } - - console.log('[EditPricingPage] 단가 수정 성공:', result.data, { isRevision, revisionReason }); - } - - // 서버 액션: 단가 확정 - async function handleFinalize(priceId: string) { - 'use server'; - - const result = await finalizePricing(priceId); - - if (!result.success) { - throw new Error(result.error || '단가 확정에 실패했습니다.'); - } - - console.log('[EditPricingPage] 단가 확정 성공:', result.data); - } - return ( ); -} +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx index a382901b..304cc9d9 100644 --- a/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx +++ b/src/app/[locale]/(protected)/sales/pricing-management/create/page.tsx @@ -1,5 +1,7 @@ +'use client'; + /** - * 단가 등록 페이지 + * 단가 등록 페이지 (Client Component) * * 경로: /sales/pricing-management/create?itemId=xxx * API: POST /api/v1/pricing @@ -7,63 +9,95 @@ * item_type_code는 품목 정보에서 자동으로 가져옴 (FG, PT, SM, RM, CS 등) */ +import { useEffect, useState } from 'react'; +import { useSearchParams, useRouter } from 'next/navigation'; import { PricingFormClient } from '@/components/pricing'; import { getItemInfo, createPricing } from '@/components/pricing/actions'; -import type { PricingData } from '@/components/pricing'; +import type { PricingData, ItemInfo } from '@/components/pricing'; -interface CreatePricingPageProps { - searchParams: Promise<{ - itemId?: string; - itemCode?: string; - }>; -} +export default function CreatePricingPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const itemId = searchParams.get('itemId') || ''; -export default async function CreatePricingPage({ searchParams }: CreatePricingPageProps) { - const params = await searchParams; - const itemId = params.itemId || ''; + const [itemInfo, setItemInfo] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - // 품목 정보 조회 - const itemInfo = itemId ? await getItemInfo(itemId) : null; + useEffect(() => { + if (!itemId) { + setIsLoading(false); + return; + } - if (!itemInfo && itemId) { + getItemInfo(itemId) + .then(result => { + if (result) { + setItemInfo(result); + } else { + setError('품목 정보를 찾을 수 없습니다.'); + } + }) + .catch(() => { + setError('품목 정보를 불러오는 중 오류가 발생했습니다.'); + }) + .finally(() => setIsLoading(false)); + }, [itemId]); + + // 단가 등록 핸들러 + const handleSave = async (data: PricingData) => { + const result = await createPricing(data); + if (!result.success) { + throw new Error(result.error || '단가 등록에 실패했습니다.'); + } + console.log('[CreatePricingPage] 단가 등록 성공:', result.data); + }; + + if (isLoading) { return ( -
-
-

품목 정보를 찾을 수 없습니다

-

- 올바른 품목 정보로 다시 시도해주세요. -

-
+
+
로딩 중...
); } - // 품목 정보 없이 접근한 경우 (목록에서 바로 등록) - if (!itemInfo) { + // 품목 정보 없이 접근한 경우 + if (!itemId) { return (

품목을 선택해주세요

-

+

단가 목록에서 품목을 선택한 후 등록해주세요.

+
); } - // 서버 액션: 단가 등록 - // item_type_code는 data.itemType에서 자동으로 가져옴 - async function handleSave(data: PricingData) { - 'use server'; - - const result = await createPricing(data); - - if (!result.success) { - throw new Error(result.error || '단가 등록에 실패했습니다.'); - } - - console.log('[CreatePricingPage] 단가 등록 성공:', result.data); + if (error || !itemInfo) { + return ( +
+
+

{error || '품목 정보를 찾을 수 없습니다'}

+

+ 올바른 품목 정보로 다시 시도해주세요. +

+ +
+
+ ); } return ( @@ -73,4 +107,4 @@ export default async function CreatePricingPage({ searchParams }: CreatePricingP onSave={handleSave} /> ); -} +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/pricing-management/page.tsx b/src/app/[locale]/(protected)/sales/pricing-management/page.tsx index 5b9ef67e..fa8f94e2 100644 --- a/src/app/[locale]/(protected)/sales/pricing-management/page.tsx +++ b/src/app/[locale]/(protected)/sales/pricing-management/page.tsx @@ -1,5 +1,7 @@ +'use client'; + /** - * 단가 목록 페이지 + * 단가 목록 페이지 (Client Component) * * 경로: /sales/pricing-management * API: @@ -10,302 +12,29 @@ * 품목 목록 + 단가 목록 → 병합 → 품목별 단가 현황 표시 */ +import { useEffect, useState } from 'react'; import { PricingListClient } from '@/components/pricing'; -import type { PricingListItem, PricingStatus } from '@/components/pricing'; -import { cookies } from 'next/headers'; +import { getPricingListData, type PricingListItem } from '@/components/pricing/actions'; -// ============================================ -// API 응답 타입 정의 -// ============================================ +export default function PricingManagementPage() { + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(true); -// 품목 API 응답 타입 (GET /api/v1/items) -interface ItemApiData { - id: number; - item_type: string; // FG, PT, SM, RM, CS (품목 유형) - code: string; - name: string; - unit: string; - category_id: number | null; - created_at: string; - deleted_at: string | null; -} + useEffect(() => { + getPricingListData() + .then(result => { + setData(result); + }) + .finally(() => setIsLoading(false)); + }, []); -interface ItemsApiResponse { - success: boolean; - data: { - current_page: number; - data: ItemApiData[]; - total: number; - per_page: number; - last_page: number; - }; - message: string; -} - -// 단가 API 응답 타입 (GET /api/v1/pricing) -interface PriceApiItem { - id: number; - tenant_id: number; - item_type_code: string; // FG, PT, SM, RM, CS (items.item_type과 동일) - item_id: number; - client_group_id: number | null; - purchase_price: string | null; - processing_cost: string | null; - loss_rate: string | null; - margin_rate: string | null; - sales_price: string | null; - rounding_rule: 'round' | 'ceil' | 'floor'; - rounding_unit: number; - supplier: string | null; - effective_from: string; - effective_to: string | null; - status: 'draft' | 'active' | 'finalized'; - is_final: boolean; - finalized_at: string | null; - finalized_by: number | null; - note: string | null; - created_at: string; - updated_at: string; - deleted_at: string | null; - client_group?: { - id: number; - name: string; - }; - product?: { - id: number; - product_code: string; - product_name: string; - specification: string | null; - unit: string; - product_type: string; - }; - material?: { - id: number; - item_code: string; - item_name: string; - specification: string | null; - unit: string; - product_type: string; - }; -} - -interface PricingApiResponse { - success: boolean; - data: { - current_page: number; - data: PriceApiItem[]; - total: number; - per_page: number; - last_page: number; - }; - message: string; -} - -// ============================================ -// 헬퍼 함수 -// ============================================ - -// API 헤더 생성 -async function getApiHeaders(): Promise { - const cookieStore = await cookies(); - const token = cookieStore.get('access_token')?.value; - - return { - 'Accept': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '', - 'X-API-KEY': process.env.API_KEY || '', - }; -} - -// 품목 유형 매핑 (type_code → 프론트엔드 ItemType) -function mapItemType(typeCode?: string): string { - switch (typeCode) { - case 'FG': return 'FG'; // 제품 - case 'PT': return 'PT'; // 부품 - case 'SM': return 'SM'; // 부자재 - case 'RM': return 'RM'; // 원자재 - case 'CS': return 'CS'; // 소모품 - default: return 'PT'; - } -} - -// API 상태 → 프론트엔드 상태 매핑 -function mapStatus(apiStatus: string, isFinal: boolean): PricingStatus | 'not_registered' { - if (isFinal) return 'finalized'; - switch (apiStatus) { - case 'draft': return 'draft'; - case 'active': return 'active'; - case 'finalized': return 'finalized'; - default: return 'draft'; - } -} - -// ============================================ -// API 호출 함수 -// ============================================ - -// 품목 목록 조회 -async function getItemsList(): Promise { - try { - const headers = await getApiHeaders(); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?group_id=1&size=100`, - { - method: 'GET', - headers, - cache: 'no-store', - } + if (isLoading) { + return ( +
+
로딩 중...
+
); - - if (!response.ok) { - console.error('[PricingPage] Items API Error:', response.status, response.statusText); - return []; - } - - const result: ItemsApiResponse = await response.json(); - - if (!result.success || !result.data?.data) { - console.warn('[PricingPage] No items data in response'); - return []; - } - - return result.data.data; - } catch (error) { - console.error('[PricingPage] Items fetch error:', error); - return []; - } -} - -// 단가 목록 조회 -async function getPricingList(): Promise { - try { - const headers = await getApiHeaders(); - - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing?size=100`, - { - method: 'GET', - headers, - cache: 'no-store', - } - ); - - if (!response.ok) { - console.error('[PricingPage] Pricing API Error:', response.status, response.statusText); - return []; - } - - const result: PricingApiResponse = await response.json(); - console.log('[PricingPage] Pricing API Response count:', result.data?.data?.length || 0); - - if (!result.success || !result.data?.data) { - console.warn('[PricingPage] No pricing data in response'); - return []; - } - - return result.data.data; - } catch (error) { - console.error('[PricingPage] Pricing fetch error:', error); - return []; - } -} - -// ============================================ -// 데이터 병합 함수 -// ============================================ - -/** - * 품목 목록 + 단가 목록 병합 - * - * - 품목 목록을 기준으로 순회 - * - 각 품목에 해당하는 단가 정보를 매핑 (item_type + item_id로 매칭) - * - 단가 미등록 품목은 'not_registered' 상태로 표시 - */ -function mergeItemsWithPricing( - items: ItemApiData[], - pricings: PriceApiItem[] -): PricingListItem[] { - // 단가 정보를 빠르게 찾기 위한 Map 생성 - // key: "{item_type}_{item_id}" (예: "FG_123", "PT_456") - const pricingMap = new Map(); - - for (const pricing of pricings) { - const key = `${pricing.item_type_code}_${pricing.item_id}`; - // 같은 품목에 여러 단가가 있을 수 있으므로 최신 것만 사용 - if (!pricingMap.has(key)) { - pricingMap.set(key, pricing); - } } - // 품목 목록을 기준으로 병합 - return items.map((item) => { - const key = `${item.item_type}_${item.id}`; - const pricing = pricingMap.get(key); - - if (pricing) { - // 단가 등록된 품목 - return { - id: String(pricing.id), - itemId: String(item.id), - itemCode: item.code, - itemName: item.name, - itemType: mapItemType(item.item_type), - specification: undefined, // items API에서는 specification 미제공 - unit: item.unit || 'EA', - purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined, - processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined, - salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined, - marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined, - effectiveDate: pricing.effective_from, - status: mapStatus(pricing.status, pricing.is_final), - currentRevision: 0, - isFinal: pricing.is_final, - itemTypeCode: item.item_type, // FG, PT, SM, RM, CS (단가 등록 시 필요) - }; - } else { - // 단가 미등록 품목 - return { - id: `item_${item.id}`, // 임시 ID (단가 ID가 없으므로) - itemId: String(item.id), - itemCode: item.code, - itemName: item.name, - itemType: mapItemType(item.item_type), - specification: undefined, - unit: item.unit || 'EA', - purchasePrice: undefined, - processingCost: undefined, - salesPrice: undefined, - marginRate: undefined, - effectiveDate: undefined, - status: 'not_registered' as const, - currentRevision: 0, - isFinal: false, - itemTypeCode: item.item_type, // FG, PT, SM, RM, CS (단가 등록 시 필요) - }; - } - }); -} - -// ============================================ -// 페이지 컴포넌트 -// ============================================ - -export default async function PricingManagementPage() { - // 품목 목록과 단가 목록을 병렬로 조회 - const [items, pricings] = await Promise.all([ - getItemsList(), - getPricingList(), - ]); - - console.log('[PricingPage] Items count:', items.length); - console.log('[PricingPage] Pricings count:', pricings.length); - - // 데이터 병합 - const mergedData = mergeItemsWithPricing(items, pricings); - console.log('[PricingPage] Merged data count:', mergedData.length); - - return ( - - ); + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/quote-management/page.tsx b/src/app/[locale]/(protected)/sales/quote-management/page.tsx index 6411c400..8e5b7259 100644 --- a/src/app/[locale]/(protected)/sales/quote-management/page.tsx +++ b/src/app/[locale]/(protected)/sales/quote-management/page.tsx @@ -1,20 +1,48 @@ +'use client'; + /** - * 견적관리 페이지 (Server Component) + * 견적관리 페이지 (Client Component) * - * 초기 데이터를 서버에서 fetch하여 Client Component에 전달 + * 초기 데이터를 useEffect에서 fetch하여 Client Component에 전달 */ +import { useEffect, useState } from 'react'; import { QuoteManagementClient } from '@/components/quotes/QuoteManagementClient'; import { getQuotes } from '@/components/quotes/actions'; -export default async function QuoteManagementPage() { - // 서버에서 초기 데이터 조회 - const result = await getQuotes({ perPage: 100 }); +const DEFAULT_PAGINATION = { + currentPage: 1, + lastPage: 1, + perPage: 100, + total: 0, +}; + +export default function QuoteManagementPage() { + const [data, setData] = useState>['data']>([]); + const [pagination, setPagination] = useState(DEFAULT_PAGINATION); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getQuotes({ perPage: 100 }) + .then(result => { + setData(result.data); + setPagination(result.pagination); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } return ( ); -} +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/settings/account-info/page.tsx b/src/app/[locale]/(protected)/settings/account-info/page.tsx index 70f96533..98c960f2 100644 --- a/src/app/[locale]/(protected)/settings/account-info/page.tsx +++ b/src/app/[locale]/(protected)/settings/account-info/page.tsx @@ -1,38 +1,61 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { AccountInfoClient } from '@/components/settings/AccountInfoManagement'; import { getAccountInfo } from '@/components/settings/AccountInfoManagement/actions'; +import type { AccountInfo, TermsAgreement, MarketingConsent } from '@/components/settings/AccountInfoManagement/types'; -export default async function AccountInfoPage() { - const result = await getAccountInfo(); +const DEFAULT_ACCOUNT_INFO: AccountInfo = { + id: '', + email: '', + profileImage: undefined, + role: '', + status: 'active', + isTenantMaster: false, + createdAt: '', + updatedAt: '', +}; - if (!result.success || !result.data) { - // 실패 시 빈 데이터로 렌더링 (클라이언트에서 에러 처리) +const DEFAULT_MARKETING_CONSENT: MarketingConsent = { + email: { agreed: false }, + sms: { agreed: false }, +}; + +export default function AccountInfoPage() { + const [accountInfo, setAccountInfo] = useState(DEFAULT_ACCOUNT_INFO); + const [termsAgreements, setTermsAgreements] = useState([]); + const [marketingConsent, setMarketingConsent] = useState(DEFAULT_MARKETING_CONSENT); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + getAccountInfo() + .then(result => { + if (result.success && result.data) { + setAccountInfo(result.data.accountInfo); + setTermsAgreements(result.data.termsAgreements); + setMarketingConsent(result.data.marketingConsent); + } else { + setError(result.error); + } + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { return ( - +
+
로딩 중...
+
); } return ( ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/settings/notification-settings/page.tsx b/src/app/[locale]/(protected)/settings/notification-settings/page.tsx index d1ab4306..56bc40a5 100644 --- a/src/app/[locale]/(protected)/settings/notification-settings/page.tsx +++ b/src/app/[locale]/(protected)/settings/notification-settings/page.tsx @@ -1,8 +1,32 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { NotificationSettingsManagement } from '@/components/settings/NotificationSettings'; import { getNotificationSettings } from '@/components/settings/NotificationSettings/actions'; +import { DEFAULT_NOTIFICATION_SETTINGS } from '@/components/settings/NotificationSettings/types'; +import type { NotificationSettings } from '@/components/settings/NotificationSettings/types'; -export default async function NotificationSettingsPage() { - const result = await getNotificationSettings(); +export default function NotificationSettingsPage() { + const [data, setData] = useState(DEFAULT_NOTIFICATION_SETTINGS); + const [isLoading, setIsLoading] = useState(true); - return ; + useEffect(() => { + getNotificationSettings() + .then(result => { + if (result.success) { + setData(result.data); + } + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ; } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/settings/permissions/[id]/page.tsx b/src/app/[locale]/(protected)/settings/permissions/[id]/page.tsx index e369015b..a761ad2f 100644 --- a/src/app/[locale]/(protected)/settings/permissions/[id]/page.tsx +++ b/src/app/[locale]/(protected)/settings/permissions/[id]/page.tsx @@ -1,10 +1,13 @@ +'use client'; + +import { use } from 'react'; import { PermissionDetailClient } from '@/components/settings/PermissionManagement/PermissionDetailClient'; interface PageProps { params: Promise<{ id: string }>; } -export default async function PermissionDetailPage({ params }: PageProps) { - const { id } = await params; +export default function PermissionDetailPage({ params }: PageProps) { + const { id } = use(params); return ; -} \ No newline at end of file +} diff --git a/src/app/[locale]/(protected)/settings/popup-management/page.tsx b/src/app/[locale]/(protected)/settings/popup-management/page.tsx index 0f60417b..5f6454c2 100644 --- a/src/app/[locale]/(protected)/settings/popup-management/page.tsx +++ b/src/app/[locale]/(protected)/settings/popup-management/page.tsx @@ -1,8 +1,29 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { PopupList } from '@/components/settings/PopupManagement'; import { getPopups } from '@/components/settings/PopupManagement/actions'; +import type { Popup } from '@/components/settings/PopupManagement/types'; -export default async function PopupManagementPage() { - const popups = await getPopups({ size: 100 }); +export default function PopupManagementPage() { + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(true); - return ; -} + useEffect(() => { + getPopups({ size: 100 }) + .then(result => { + setData(result); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/subscription/page.tsx b/src/app/[locale]/(protected)/subscription/page.tsx index ee8baaf4..d3f4291f 100644 --- a/src/app/[locale]/(protected)/subscription/page.tsx +++ b/src/app/[locale]/(protected)/subscription/page.tsx @@ -1,8 +1,28 @@ +'use client'; + +import { useEffect, useState } from 'react'; import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement'; import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions'; -export default async function SubscriptionPage() { - const result = await getSubscriptionData(); +export default function SubscriptionPage() { + const [data, setData] = useState>['data']>(undefined); + const [isLoading, setIsLoading] = useState(true); - return ; + useEffect(() => { + getSubscriptionData() + .then(result => { + setData(result.data); + }) + .finally(() => setIsLoading(false)); + }, []); + + if (isLoading) { + return ( +
+
로딩 중...
+
+ ); + } + + return ; } \ 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/dialogs/DashboardSettingsDialog.tsx b/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx index d00673a8..4d67e1b0 100644 --- a/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx +++ b/src/components/business/CEODashboard/dialogs/DashboardSettingsDialog.tsx @@ -198,7 +198,7 @@ export function DashboardSettingsDialog({ onClose(); }, [settings, onClose]); - // 커스텀 스위치 (ON/OFF 라벨 포함) + // 커스텀 스위치 (라이트 테마용) const ToggleSwitch = ({ checked, onCheckedChange, @@ -210,36 +210,20 @@ export function DashboardSettingsDialog({ type="button" onClick={() => onCheckedChange(!checked)} className={cn( - 'relative inline-flex h-7 w-14 items-center rounded-full transition-colors', - checked ? 'bg-cyan-500' : 'bg-gray-300' + 'relative inline-flex h-6 w-11 items-center rounded-full transition-colors', + checked ? 'bg-blue-500' : 'bg-gray-300' )} > - ON - - - OFF - - ); - // 섹션 행 컴포넌트 + // 섹션 행 컴포넌트 (라이트 테마) const SectionRow = ({ label, checked, @@ -258,11 +242,16 @@ export function DashboardSettingsDialog({ children?: React.ReactNode; }) => ( -
+
{hasExpand && ( - )} - {label} + {label}
{children && ( - + {children} )} @@ -285,30 +274,30 @@ export function DashboardSettingsDialog({ return ( !open && handleCancel()}> - - - 항목 설정 + + + 항목 설정 -
+
{/* 오늘의 이슈 섹션 */} -
-
- 오늘의 이슈 +
+
+ 오늘의 이슈
{localSettings.todayIssue.enabled && ( -
+
{(Object.keys(TODAY_ISSUE_LABELS) as Array).map( (key) => (
- + {TODAY_ISSUE_LABELS[key]} - + {/* ■ 중소기업 판단 기준표 */}
- + 중소기업 판단 기준표
- - - + + + - - - + + + - - - + + + - - - + + +
조건기준충족 요건조건기준충족 요건
① 매출액업종별 상이업종별 기준 금액 이하① 매출액업종별 상이업종별 기준 금액 이하
② 자산총액5,000억원미만② 자산총액5,000억원미만
③ 독립성소유·경영대기업 계열 아님③ 독립성소유·경영대기업 계열 아님
@@ -451,20 +440,20 @@ export function DashboardSettingsDialog({ - - + + - - - - - - - - - + + + + + + + + +
업종 분류기준 매출액업종 분류기준 매출액
제조업1,500억원 이하
건설업1,000억원 이하
운수업1,000억원 이하
도매업1,000억원 이하
소매업600억원 이하
정보통신업600억원 이하
전문서비스업600억원 이하
숙박·음식점업400억원 이하
기타 서비스업400억원 이하
제조업1,500억원 이하
건설업1,000억원 이하
운수업1,000억원 이하
도매업1,000억원 이하
소매업600억원 이하
정보통신업600억원 이하
전문서비스업600억원 이하
숙박·음식점업400억원 이하
기타 서비스업400억원 이하
@@ -477,14 +466,14 @@ export function DashboardSettingsDialog({ - - + + - - + +
구분기준구분기준
5,000억원 미만직전 사업연도 말 자산총액5,000억원 미만직전 사업연도 말 자산총액
@@ -498,31 +487,31 @@ export function DashboardSettingsDialog({ - - - + + + - - - + + + - - - + + + - - - + + + - - - + + +
구분내용판정구분내용판정
독립기업아래 항목에 모두 해당하지 않음충족독립기업아래 항목에 모두 해당하지 않음충족
기업집단 소속공정거래법상 상호출자제한 기업집단 소속미충족기업집단 소속공정거래법상 상호출자제한 기업집단 소속미충족
대기업 지분대기업이 발행주식 30% 이상 보유미충족대기업 지분대기업이 발행주식 30% 이상 보유미충족
관계기업 합산관계기업 포함 시 매출액·자산 기준 초과미충족관계기업 합산관계기업 포함 시 매출액·자산 기준 초과미충족
@@ -531,27 +520,27 @@ export function DashboardSettingsDialog({ {/* ■ 판정 결과 */}
- + 판정 결과
- - - + + + - - - + + + - - - + + +
판정조건접대비 기본한도판정조건접대비 기본한도
중소기업①②③ 모두 충족3,600만원중소기업①②③ 모두 충족3,600만원
일반법인①②③ 중 하나라도 미충족1,200만원일반법인①②③ 중 하나라도 미충족1,200만원
@@ -699,11 +688,18 @@ export function DashboardSettingsDialog({ />
- - - 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/pricing/actions.ts b/src/components/pricing/actions.ts index ce120ca4..b84557b5 100644 --- a/src/components/pricing/actions.ts +++ b/src/components/pricing/actions.ts @@ -475,6 +475,186 @@ export async function finalizePricing(id: string): Promise<{ success: boolean; d } } +// ============================================ +// 품목 목록 + 단가 목록 병합 조회 +// ============================================ + +// 품목 API 응답 타입 (GET /api/v1/items) +interface ItemApiData { + id: number; + item_type: string; // FG, PT, SM, RM, CS (품목 유형) + code: string; + name: string; + unit: string; + category_id: number | null; + created_at: string; + deleted_at: string | null; +} + +// 단가 목록 조회용 타입 +interface PriceApiListItem { + id: number; + tenant_id: number; + item_type_code: string; + item_id: number; + client_group_id: number | null; + purchase_price: string | null; + processing_cost: string | null; + loss_rate: string | null; + margin_rate: string | null; + sales_price: string | null; + rounding_rule: 'round' | 'ceil' | 'floor'; + rounding_unit: number; + supplier: string | null; + effective_from: string; + effective_to: string | null; + status: 'draft' | 'active' | 'finalized'; + is_final: boolean; + finalized_at: string | null; + finalized_by: number | null; + note: string | null; + created_at: string; + updated_at: string; + deleted_at: string | null; +} + +// 목록 표시용 타입 +export interface PricingListItem { + id: string; + itemId: string; + itemCode: string; + itemName: string; + itemType: string; + specification?: string; + unit: string; + purchasePrice?: number; + processingCost?: number; + salesPrice?: number; + marginRate?: number; + effectiveDate?: string; + status: 'draft' | 'active' | 'finalized' | 'not_registered'; + currentRevision: number; + isFinal: boolean; + itemTypeCode: string; +} + +// 품목 유형 매핑 (type_code → 프론트엔드 ItemType) +function mapItemTypeForList(typeCode?: string): string { + switch (typeCode) { + case 'FG': return 'FG'; + case 'PT': return 'PT'; + case 'SM': return 'SM'; + case 'RM': return 'RM'; + case 'CS': return 'CS'; + default: return 'PT'; + } +} + +// API 상태 → 프론트엔드 상태 매핑 +function mapStatusForList(apiStatus: string, isFinal: boolean): 'draft' | 'active' | 'finalized' | 'not_registered' { + if (isFinal) return 'finalized'; + switch (apiStatus) { + case 'draft': return 'draft'; + case 'active': return 'active'; + case 'finalized': return 'finalized'; + default: return 'draft'; + } +} + +/** + * 단가 목록 데이터 조회 (품목 + 단가 병합) + */ +export async function getPricingListData(): Promise { + try { + // 품목 목록 조회 + const { response: itemsResponse, error: itemsError } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/items?group_id=1&size=100`, + { method: 'GET' } + ); + + if (itemsError || !itemsResponse) { + console.error('[PricingActions] Items fetch error:', itemsError?.message); + return []; + } + + const itemsResult = await itemsResponse.json(); + const items: ItemApiData[] = itemsResult.success ? (itemsResult.data?.data || []) : []; + + // 단가 목록 조회 + const { response: pricingResponse, error: pricingError } = await serverFetch( + `${process.env.NEXT_PUBLIC_API_URL}/api/v1/pricing?size=100`, + { method: 'GET' } + ); + + if (pricingError || !pricingResponse) { + console.error('[PricingActions] Pricing fetch error:', pricingError?.message); + return []; + } + + const pricingResult = await pricingResponse.json(); + const pricings: PriceApiListItem[] = pricingResult.success ? (pricingResult.data?.data || []) : []; + + // 단가 정보를 빠르게 찾기 위한 Map 생성 + const pricingMap = new Map(); + for (const pricing of pricings) { + const key = `${pricing.item_type_code}_${pricing.item_id}`; + if (!pricingMap.has(key)) { + pricingMap.set(key, pricing); + } + } + + // 품목 목록을 기준으로 병합 + return items.map((item) => { + const key = `${item.item_type}_${item.id}`; + const pricing = pricingMap.get(key); + + if (pricing) { + return { + id: String(pricing.id), + itemId: String(item.id), + itemCode: item.code, + itemName: item.name, + itemType: mapItemTypeForList(item.item_type), + specification: undefined, + unit: item.unit || 'EA', + purchasePrice: pricing.purchase_price ? parseFloat(pricing.purchase_price) : undefined, + processingCost: pricing.processing_cost ? parseFloat(pricing.processing_cost) : undefined, + salesPrice: pricing.sales_price ? parseFloat(pricing.sales_price) : undefined, + marginRate: pricing.margin_rate ? parseFloat(pricing.margin_rate) : undefined, + effectiveDate: pricing.effective_from, + status: mapStatusForList(pricing.status, pricing.is_final), + currentRevision: 0, + isFinal: pricing.is_final, + itemTypeCode: item.item_type, + }; + } else { + return { + id: `item_${item.id}`, + itemId: String(item.id), + itemCode: item.code, + itemName: item.name, + itemType: mapItemTypeForList(item.item_type), + specification: undefined, + unit: item.unit || 'EA', + purchasePrice: undefined, + processingCost: undefined, + salesPrice: undefined, + marginRate: undefined, + effectiveDate: undefined, + status: 'not_registered' as const, + currentRevision: 0, + isFinal: false, + itemTypeCode: item.item_type, + }; + } + }); + } catch (error) { + if (isNextRedirectError(error)) throw error; + console.error('[PricingActions] getPricingListData error:', error); + return []; + } +} + /** * 단가 이력 조회 */ 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..5bd1c4ed --- /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 diff --git a/src/lib/api/fetch-wrapper.ts b/src/lib/api/fetch-wrapper.ts index bf01cd4d..6242288a 100644 --- a/src/lib/api/fetch-wrapper.ts +++ b/src/lib/api/fetch-wrapper.ts @@ -107,7 +107,9 @@ export async function getServerApiHeaders(token?: string): Promise */ export async function serverFetch( url: string, - options?: RequestInit & { skipAuthCheck?: boolean } + options?: RequestInit & { + skipAuthCheck?: boolean; + } ): Promise<{ response: Response | null; error: ApiErrorResponse | null }> { try { const cookieStore = await cookies();