refactor(WEB): 회계/차량/결재 등 코드 중복 제거 및 공통 훅 추출

- useAccountingListPage, useDateRange 공통 훅 추출
- accounting/shared/ 공통 컴포넌트 분리
- 회계 모듈(입금/출금/매출/매입/청구 등) 중복 로직 통합
- 차량관리 page.tsx 패턴 간소화
- 건설/결재/자재/출하/단가 등 날짜 관련 코드 공통화
- 코드 중복 제거 체크리스트 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-19 19:01:41 +09:00
parent a2c3e4c41e
commit 71352923c8
80 changed files with 833 additions and 587 deletions

View File

@@ -0,0 +1,306 @@
# 코드 중복 제거 및 공통화 체크리스트
> 작성일: 2026-02-19
> 기반: 4개 에이전트 병렬 분석 결과 (회계 page.tsx / 회계 컴포넌트 / 유틸 중복 / 비회계 페이지)
> 구조: 6개 작업 패키지 (WP) — 의존성 기반 병렬화 설계
---
## 작업 패키지 의존성 맵
```
Phase 1 (독립 — 전부 병렬 가능):
[x] WP-1: 날짜 하드코딩 긴급 수정 (10파일) ✅
[x] WP-2: formatAmount/formatDate import 통일 (35+파일) ✅
[x] WP-3: 차량관리 edit 페이지 리다이렉트 전환 (6파일) ✅
Phase 2 (Phase 1 완료 후 — 서로 간 병렬 가능):
[x] WP-4: 공통 훅 추출 (useDateRange, useAccountingListPage 등) ✅
[x] WP-5: 회계 컴포넌트 공통 패턴 추출 (팩토리 함수, 공통 컴포넌트) ✅
Phase 3 (Phase 2 완료 후):
[x] WP-6: .toISOString().split('T')[0] → getTodayString() 전환 (15파일 26건) ✅
```
### 병렬 실행 판단 기준
| 판단 | 조건 |
|------|------|
| **병렬 가능** | 수정 파일이 겹치지 않고, 새 유틸/훅에 의존하지 않음 |
| **순차 필요** | 새로 만드는 훅/유틸을 다른 WP에서 import해야 하는 경우 |
---
## WP-1: 날짜 필터 하드코딩 긴급 수정 ✅ 완료 (2026-02-19)
**심각도**: 🔴 CRITICAL (데이터 누락 버그)
**난이도**: 낮음 | **파일 수**: 10 | **예상 변경량**: 각 2줄
**병렬**: Phase 1 — 독립 실행 가능
### 현황
매출관리와 동일한 버그. `2025-xx-xx` 하드코딩으로 잘못된 기간 데이터 표시.
### 수정 완료
**WP-4에서 useDateRange('currentYear') 적용으로 이미 수정된 파일:**
- [x] `accounting/DepositManagement/index.tsx`
- [x] `accounting/WithdrawalManagement/index.tsx`
- [x] `accounting/BillManagement/index.tsx`
**이번 세션에서 추가 발견 및 수정한 파일 (동일 버그):**
- [x] `accounting/BillManagement/BillManagementClient.tsx` — 실제 page에서 사용되는 컴포넌트 (`'2025-09-01'`~`'2025-09-03'``useDateRange('currentYear')`)
- [x] `accounting/PurchaseManagement/index.tsx` — (`'2025-01-01'`~`'2025-12-31'``useDateRange('currentYear')`)
- [x] `approval/ApprovalBox/index.tsx` — (`'2025-09-01'`~`'2025-09-03'``useDateRange('currentYear')`)
- [x] `approval/ReferenceBox/index.tsx` — (`'2025-09-01'`~`'2025-09-03'``useDateRange('currentYear')`)
- [x] `approval/DraftBox/index.tsx` — (`'2025-01-01'`~`'2025-12-31'``useDateRange('currentYear')`)
- [x] `process-management/ProcessListClient.tsx` — (`'2025-01-01'`~`'2025-12-31'``useDateRange('currentYear')`)
- [x] `hr/VacationManagement/index.tsx` — (`'2025-12-01'`~`'2025-12-31'``useDateRange('currentMonth')`)
- [x] `hr/SalaryManagement/index.tsx` — (`'2025-12-01'`~`'2025-12-31'``useDateRange('currentMonth')`)
- [x] `pricing-table-management/PricingTableListClient.tsx` — (`'2025-01-01'`~`'2025-12-31'``useDateRange('currentYear')`)
- [x] `checklist-management/ChecklistListClient.tsx` — (`'2025-01-01'`~`'2025-12-31'``useDateRange('currentYear')`)
### 검증
- [x] `npx tsc --noEmit` 통과
- [x] `useState('2025` 잔존 0건 확인
---
## WP-2: formatAmount / formatDate import 통일 ✅ 완료 (2026-02-19)
**심각도**: 🟡 HIGH (코드 품질)
**난이도**: 낮음 | **파일 수**: 24파일 수정 / 10파일 스킵 | **예상 변경량**: 각 파일 2-5줄 (로컬 함수 삭제 + import 추가)
**병렬**: Phase 1 — 독립 실행 가능
### 2-A: formatAmount/formatNumber/formatCurrency 통일 — 21파일 완료
**canonical**: `src/lib/utils/amount.ts``formatNumber()`, `formatAmountWon()`
- [x] VehicleDispatch 3파일 → `import { formatNumber as formatAmount } from '@/lib/utils/amount'`
- [x] 결재 문서 5파일 → `import { formatNumber as formatCurrency } from '@/lib/utils/amount'`
- [x] SalesDetail, PurchaseDetail → `import { formatNumber as formatAmount } from '@/lib/utils/amount'`
- [x] GiftCertificateManagement, DailyReport → `import { formatNumber as formatAmount } from '@/lib/utils/amount'`
- [x] StageCard, ProjectCard → `import { formatNumber as formatAmount } from '@/lib/utils/amount'`
- [x] ProjectListClient → `import { formatAmountWon as formatAmount } from '@/lib/utils/amount'`
- [x] PricingTableForm, PricingTableListClient → `import { formatNumber } from '@/lib/utils/amount'`
- [x] DirectConstructionContent, IndirectConstructionContent → `import { formatNumber } from '@/lib/utils/amount'`
- [x] SubscriptionClient, SubscriptionManagement → `import { formatAmountWon as formatCurrency } from '@/lib/utils/amount'`
**스킵 (다른 동작):**
- ReceivablesStatus (0일 때 '' 반환), VendorLedger (0일 때 '' 반환), VendorLedgerDetail (괄호 옵션)
- CEODashboard/components.tsx (showUnit 옵션)
### 2-B: formatDate 통일 — 3파일 완료
**canonical**: `src/lib/utils/date.ts``formatDate()`
- [x] OrderManagementListClient, OrderManagementUnified, ConstructionManagementListClient → `import { formatDate } from '@/lib/utils/date'`
**스킵 (다른 출력 형식 — canonical formatDate와 비호환):**
- BoardDetail, BoardForm (formatDateTime: `YYYY-MM-DD HH:MM`)
- SubscriptionClient, SubscriptionManagement (한국어: `YYYY년 M월 D일`)
- PermissionManagement (date-fns format 사용)
- ProductionDashboard (`M/D` 형식)
- HandoverReportDocumentModal (한국어 long date)
### 검증
- [x] TypeScript 빌드 에러 없음 확인
- [x] 각 수정 파일에서 포맷 결과가 기존과 동일한지 확인 (동일 로직 import)
---
## WP-3: 차량관리 edit 페이지 리다이렉트 전환 ✅ 이전 세션에서 완료
**심각도**: 🟡 HIGH (코드 중복)
**난이도**: 낮음 | **파일 수**: 6 (3쌍)
**병렬**: Phase 1 — 독립 실행 가능
### 현황
이전 세션에서 이미 완료됨. 3개 edit 페이지 모두 리다이렉트 스텁으로 교체되어 있고,
3개 `[id]/page.tsx` 모두 `useSearchParams`로 mode를 읽고 있음.
### 수정 완료
- [x] `vehicle-management/vehicle/[id]/edit/page.tsx``router.replace(...?mode=edit)` 리다이렉트 스텁
- [x] `vehicle-management/vehicle/[id]/page.tsx``useSearchParams` + mode 처리
- [x] `vehicle-management/forklift/[id]/edit/page.tsx` → 리다이렉트 스텁
- [x] `vehicle-management/forklift/[id]/page.tsx``useSearchParams` + mode 처리
- [x] `vehicle-management/vehicle-log/[id]/edit/page.tsx` → 리다이렉트 스텁
- [x] `vehicle-management/vehicle-log/[id]/page.tsx``useSearchParams` + mode 처리
---
## WP-4: 공통 훅 추출 ✅ 완료 (2026-02-19)
**심각도**: 🟡 HIGH (아키텍처 개선)
**난이도**: 중간 | **신규 파일**: 2개 | **적용 파일**: 18개
**병렬**: Phase 2 — WP-4와 WP-5는 서로 병렬 가능.
### 4-A: `useDateRange` 훅 — 15파일 적용 완료
**위치**: `src/hooks/useDateRange.ts`
**프리셋**: `'currentYear'` | `'currentMonth'` | `'today'` | `'none'`
- [x] `src/hooks/useDateRange.ts` 생성
- [x] 회계 5개 컴포넌트 적용:
- SalesManagement → `useDateRange('currentYear')`
- DepositManagement → `useDateRange('currentYear')`
- WithdrawalManagement → `useDateRange('currentYear')`
- BillManagement → `useDateRange('currentYear')`
- GiftCertificateManagement → `useDateRange('currentMonth')` (date-fns import 제거)
- [x] 건설 10개 ListClient 적용 (모두 `useDateRange('today')` — UTC 버그도 동시 수정):
- ProgressBillingManagementListClient, OrderManagementListClient
- ConstructionManagementListClient, IssueManagementListClient
- WorkerStatusListClient, PricingListClient
- UtilityManagementListClient, SiteManagementListClient
- StructureReviewListClient, HandoverReportListClient
**스킵 (비호환):**
- ExpectedExpenseManagement (현재월 ~ 3개월 후 커스텀 범위)
- TaxInvoiceIssuance (filters 객체 패턴, 별도 날짜 관리)
### 4-B: `useAccountingListPage` 훅 — 3개 page.tsx 적용 완료
**위치**: `src/hooks/useAccountingListPage.ts`
**효과**: 50줄 → 20줄 (DEFAULT_PAGINATION + useSearchParams + useEffect + state 통합)
- [x] `src/hooks/useAccountingListPage.ts` 생성 (DEFAULT_PAGINATION 포함)
- [x] `src/hooks/index.ts`에 양쪽 훅 export 추가
- [x] 회계 3개 page.tsx 적용:
- `accounting/sales/page.tsx` (56줄 → 22줄)
- `accounting/deposits/page.tsx` (53줄 → 20줄)
- `accounting/withdrawals/page.tsx` (53줄 → 20줄)
**미적용 (패턴 비호환):**
- bills: 추가 searchParams (billType, page, vendorId)
- expected-expenses: mode 없음, 커스텀 fetch params
- gift-certificates: 리스트 자체 로딩, edit 모드 별도
- bad-debt-collection: Promise.all + summary
- vendors: total 사용 (pagination 미사용)
- tax-invoice-issuance: 복합 mode (edit+id), Promise.all
### 검증
- [x] TypeScript 빌드 에러 없음 확인
- [x] 15파일 useDateRange 적용 + 3파일 useAccountingListPage 적용
---
## WP-5: 회계 컴포넌트 공통 패턴 추출 ✅ 완료
**심각도**: 🟢 MEDIUM (코드 품질)
**난이도**: 중간 | **신규 파일**: 1개 | **영향 파일**: 5개 컴포넌트
**병렬**: Phase 2 — WP-4와 서로 병렬 가능 (겹치는 파일 없음)
### 5-A: 팩토리 함수 추출 ✅
**위치**: `src/components/accounting/shared/index.ts` (신규 생성)
4개 유틸리티 함수 생성 및 적용:
- [x] `createDeleteItemHandler(deleteFn, setData, msg)` — 4개 컴포넌트 적용 (Sales, Deposit, Withdrawal, Bill)
- 7줄 inline handler → 1줄 팩토리 호출 (컴포넌트당 -6줄)
- [x] `extractUniqueOptions(data, fieldKey, opts?)` — 5개 컴포넌트 적용 (Sales, Deposit, Withdrawal, Bill, ExpectedExpense)
- 4-6줄 useMemo body → 1줄 호출 (컴포넌트당 -3~5줄)
- [x] `createDateAmountSortFn(dateField, amountField, direction)` — 3개 컴포넌트 적용 (Sales, Deposit, Withdrawal)
- 15줄 switch문 → 1줄 팩토리 호출 (컴포넌트당 -14줄)
- Bill은 'maturityDate' 추가 정렬이 있어 제외, ExpectedExpense는 createdAt 정렬이라 제외
- [x] `computeMonthlyTotal(data, dateField, amountField)` — 3개 컴포넌트 적용 (Sales, Deposit, Withdrawal)
- 6줄 (currentMonth/currentYear + filter + reduce) → 1줄 (컴포넌트당 -5줄)
### 5-B: 스코프 조정 (계획 대비)
**적용하지 않은 항목과 사유:**
- `createFilterFn` — 각 컴포넌트의 customFilterFn이 너무 상이 (필드명, 조건문 구조 다름). 추상화 비용 > 이점
- `AccountSubjectSavePanel` — UI+로직이 각 컴포넌트마다 미묘하게 다름 (Withdrawal은 배열, Deposit은 Set). 현재 수준으로 충분
- `useRefreshableData` — Deposit/Withdrawal 2곳만 사용. 훅 생성 오버헤드가 이점을 초과
### 검증
- [x] TypeScript 빌드 에러 없음 (`npx tsc --noEmit` 통과)
- [x] 삭제/정렬/필터 로직이 기존과 동일한 동작 보장 (동일한 switch/filter 구현을 shared로 이동)
### 정량 결과
- **삭제된 중복 코드**: ~120줄 (5파일 × 평균 24줄/파일)
- **신규 공통 코드**: ~130줄 (shared/index.ts)
- **순 효과**: 중복 제거 + 단일 소스 오브 트루스 확보
---
## WP-6: toISOString UTC 버그 전환 ✅ 완료 (2026-02-19)
**심각도**: 🟢 MEDIUM (잠재적 타임존 버그)
**난이도**: 중간 | **파일 수**: 15파일 26건 | **예상 변경량**: 각 1-2줄
**병렬**: Phase 3 — WP-4의 useDateRange가 먼저 필요 (일부 파일 중복)
### 현황
`new Date().toISOString().split('T')[0]`은 UTC 기준이라 KST 00:00~08:59에 전날 날짜 반환.
`src/lib/utils/date.ts``getTodayString()`/`getLocalDateString()` 이미 존재.
### 수정 완료
**건설관리 10파일 — WP-4에서 useDateRange('today')로 이미 수정됨 (Phase 2에서 완료)**
**getTodayString() 적용 (7파일, 11건):**
- [x] `quotes/QuoteTransactionModal.tsx` — const today 초기화 (1건)
- [x] `vehicle-management/VehicleLogList/actions.ts` — writeDate + createdAt 기본값 (2건)
- [x] `pricing-table-management/actions.ts` — create/update changedDate (2건)
- [x] `pricing-table-management/PricingTableForm.tsx` — 변경일 Input value (1건)
- [x] `material/ReceivingManagement/actions.ts` — reportDate ×2 + today 변수 (3건)
- [x] `material/ReceivingManagement/ReceivingDetail.tsx` — adjustmentDate (1건)
- [x] `accounting/TaxInvoiceManagement/ManualEntryModal.tsx` — writeDate 기본값 (1건)
**getLocalDateString() 적용 (7파일, 13건):**
- [x] `pricing-distribution/PriceDistributionList.tsx` — startDate + endDate (2건)
- [x] `pricing-distribution/actions.ts` — createdAt + effectiveDate (2건)
- [x] `material/StockStatus/StockStatusList.tsx` — startDate + endDate (2건)
- [x] `quality/InspectionManagement/InspectionList.tsx` — startDate + endDate (2건)
- [x] `material/ReceivingManagement/ReceivingList.tsx` — startDate + endDate (2건)
- [x] `outbound/ShipmentManagement/ShipmentList.tsx` — startDate + endDate (2건)
- [x] `outbound/VehicleDispatchManagement/VehicleDispatchList.tsx` — startDate + endDate (2건)
**both 적용 (1파일, 2건):**
- [x] `accounting/TaxInvoiceManagement/CardHistoryModal.tsx` — today (getTodayString) + monthAgo (getLocalDateString)
### 검증
- [x] `npx tsc --noEmit` 통과 — TypeScript 에러 0건
- [x] `src/` 내 잔여 `.toISOString().split('T')[0]` = 0건 (date.ts 주석 1건 제외)
---
## 병렬 실행 계획 요약
```
┌─────────────────────────────────────────────────────────────┐
│ Phase 1 (독립 — 동시 3개 병렬) │
│ │
│ WP-1 날짜 하드코딩 WP-2 format 통일 WP-3 edit 리다이렉트 │
│ (3파일, ~10분) (35파일, ~40분) (6파일, ~20분) │
│ ↓ ↓ ↓ │
├─────────────────────────────────────────────────────────────┤
│ Phase 2 (Phase 1 완료 후 — 동시 2개 병렬) │
│ │
│ WP-4 공통 훅 추출 WP-5 컴포넌트 공통화 │
│ (신규 2-3파일 + 70파일 적용) (신규 3-5파일 + 7파일 적용) │
│ ↓ ↓ │
├─────────────────────────────────────────────────────────────┤
│ Phase 3 (Phase 2 완료 후) │
│ │
│ WP-6 toISOString UTC 버그 전환 │
│ (60+파일, 가장 큰 작업) │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Phase별 병렬 가능 근거
| Phase | WP 조합 | 병렬 가능 이유 |
|-------|---------|---------------|
| 1 | WP-1 + WP-2 + WP-3 | 수정 파일이 완전히 겹치지 않음. 기존 유틸만 사용, 신규 생성 없음 |
| 2 | WP-4 + WP-5 | WP-4는 hooks/ + page.tsx, WP-5는 components/accounting/shared/ + 컴포넌트 내부. 파일 겹침 없음 |
| 3 | WP-6 단독 | WP-4의 useDateRange 훅을 일부 파일에서 활용해야 하므로 Phase 2 이후 |
### Phase 간 의존성 상세
| 의존 관계 | 이유 |
|-----------|------|
| WP-4 → WP-1 | WP-1에서 수정한 날짜 초기값 패턴을 useDateRange 훅 설계에 반영 |
| WP-5 → WP-2 | WP-2에서 통일된 formatAmount를 공통 컴포넌트에서 import |
| WP-6 → WP-4 | useDateRange가 getTodayString()을 내부 사용하므로 훅 완성 후 적용 |

View File

@@ -571,6 +571,7 @@ claudedocs/
| 파일 | 설명 |
|------|------|
| `[IMPL-2026-02-09] phase1-common-hooks-checklist.md` | Phase 1 공통 훅 추출 체크리스트 (완료) + Phase 3 프로토타입 기록 |
| `[REF-2026-02-19] code-dedup-commonization-checklist.md` | 코드 중복 제거 및 공통화 체크리스트 (6 WP, 3 Phase 병렬 실행 계획) |
---

View File

@@ -1,49 +1,19 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { DepositManagement } from '@/components/accounting/DepositManagement';
import DepositDetailClientV2 from '@/components/accounting/DepositManagement/DepositDetailClientV2';
import { getDeposits } from '@/components/accounting/DepositManagement/actions';
import { GenericPageSkeleton } from '@/components/ui/skeleton';
const DEFAULT_PAGINATION = {
currentPage: 1,
lastPage: 1,
perPage: 100,
total: 0,
};
import { useAccountingListPage } from '@/hooks';
export default function DepositsPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
const [data, setData] = useState<Awaited<ReturnType<typeof getDeposits>>['data']>([]);
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// mode=new일 때는 데이터 로드 불필요
if (mode === 'new') {
setIsLoading(false);
return;
}
getDeposits({ perPage: 100 })
.then(result => {
setData(result.data);
setPagination(result.pagination);
})
.finally(() => setIsLoading(false));
}, [mode]);
const { data, pagination, isLoading, mode } = useAccountingListPage(
() => getDeposits({ perPage: 100 })
);
if (mode === 'new') return <DepositDetailClientV2 initialMode="create" />;
if (isLoading) return <GenericPageSkeleton />;
// mode=new일 때 등록 화면 표시
if (mode === 'new') {
return <DepositDetailClientV2 initialMode="create" />;
}
return (
<DepositManagement
initialData={data}

View File

@@ -1,50 +1,17 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { SalesManagement } from '@/components/accounting/SalesManagement';
import { SalesDetail } from '@/components/accounting/SalesManagement/SalesDetail';
import { getSales } from '@/components/accounting/SalesManagement/actions';
import { GenericPageSkeleton } from '@/components/ui/skeleton';
const DEFAULT_PAGINATION = {
currentPage: 1,
lastPage: 1,
perPage: 100,
total: 0,
};
import { useAccountingListPage } from '@/hooks';
export default function SalesPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
const [data, setData] = useState<Awaited<ReturnType<typeof getSales>>['data']>([]);
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// mode=new일 때는 데이터 로드 불필요
if (mode === 'new') {
setIsLoading(false);
return;
}
setIsLoading(true);
getSales({ perPage: 100 })
.then(result => {
if (result.success) {
setData(result.data);
setPagination(result.pagination);
}
})
.finally(() => setIsLoading(false));
}, [mode]);
// mode=new일 때 등록 화면 표시
if (mode === 'new') {
return <SalesDetail mode="new" />;
}
const { data, pagination, isLoading, mode } = useAccountingListPage(
() => getSales({ perPage: 100 })
);
if (mode === 'new') return <SalesDetail mode="new" />;
if (isLoading) return <GenericPageSkeleton />;
return (

View File

@@ -1,49 +1,19 @@
'use client';
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { WithdrawalManagement } from '@/components/accounting/WithdrawalManagement';
import WithdrawalDetailClientV2 from '@/components/accounting/WithdrawalManagement/WithdrawalDetailClientV2';
import { getWithdrawals } from '@/components/accounting/WithdrawalManagement/actions';
import { GenericPageSkeleton } from '@/components/ui/skeleton';
const DEFAULT_PAGINATION = {
currentPage: 1,
lastPage: 1,
perPage: 100,
total: 0,
};
import { useAccountingListPage } from '@/hooks';
export default function WithdrawalsPage() {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
const [data, setData] = useState<Awaited<ReturnType<typeof getWithdrawals>>['data']>([]);
const [pagination, setPagination] = useState(DEFAULT_PAGINATION);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// mode=new일 때는 데이터 로드 불필요
if (mode === 'new') {
setIsLoading(false);
return;
}
getWithdrawals({ perPage: 100 })
.then(result => {
setData(result.data);
setPagination(result.pagination);
})
.finally(() => setIsLoading(false));
}, [mode]);
const { data, pagination, isLoading, mode } = useAccountingListPage(
() => getWithdrawals({ perPage: 100 })
);
if (mode === 'new') return <WithdrawalDetailClientV2 initialMode="create" />;
if (isLoading) return <GenericPageSkeleton />;
// mode=new일 때 등록 화면 표시
if (mode === 'new') {
return <WithdrawalDetailClientV2 initialMode="create" />;
}
return (
<WithdrawalManagement
initialData={data}

View File

@@ -1,47 +1,24 @@
'use client';
/**
* 지게차 수정 페이지
* 지게차 수정 페이지 → 상세 페이지로 리다이렉트
*/
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { ForkliftDetail } from '@/components/vehicle-management/ForkliftDetail';
import { getForkliftById } from '@/components/vehicle-management/ForkliftList/actions';
import type { Forklift } from '@/components/vehicle-management/types';
import { GenericPageSkeleton } from '@/components/ui/skeleton';
import { useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
export default function ForkliftEditPage() {
const params = useParams();
const id = params.id as string;
const [data, setData] = useState<Forklift | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
if (!id) return;
router.replace(`/vehicle-management/forklift/${id}?mode=edit`);
}, [id, router]);
getForkliftById(id)
.then((result) => {
if (result.success && result.data) {
setData(result.data);
} else {
setError(result.error || '데이터를 불러올 수 없습니다.');
}
})
.finally(() => setIsLoading(false));
}, [id]);
if (isLoading) return <GenericPageSkeleton />;
if (error || !data) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-red-500">{error || '지게차를 찾을 수 없습니다.'}</div>
</div>
);
}
return <ForkliftDetail mode="edit" initialData={data} id={id} />;
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
'use client';
/**
* 지게차 상세 페이지
* 지게차 상세/수정 페이지
*/
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useParams, useSearchParams } from 'next/navigation';
import { ForkliftDetail } from '@/components/vehicle-management/ForkliftDetail';
import { getForkliftById } from '@/components/vehicle-management/ForkliftList/actions';
import type { Forklift } from '@/components/vehicle-management/types';
@@ -14,6 +14,8 @@ import { GenericPageSkeleton } from '@/components/ui/skeleton';
export default function ForkliftDetailPage() {
const params = useParams();
const id = params.id as string;
const searchParams = useSearchParams();
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
const [data, setData] = useState<Forklift | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -43,5 +45,5 @@ export default function ForkliftDetailPage() {
);
}
return <ForkliftDetail mode="view" initialData={data} id={id} />;
return <ForkliftDetail mode={mode} initialData={data} id={id} />;
}

View File

@@ -1,47 +1,24 @@
'use client';
/**
* 차량일지 수정 페이지
* 차량일지 수정 페이지 → 상세 페이지로 리다이렉트
*/
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { VehicleLogDetail } from '@/components/vehicle-management/VehicleLogDetail';
import { getVehicleLogById } from '@/components/vehicle-management/VehicleLogList/actions';
import type { VehicleLog } from '@/components/vehicle-management/types';
import { GenericPageSkeleton } from '@/components/ui/skeleton';
import { useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
export default function VehicleLogEditPage() {
const params = useParams();
const id = params.id as string;
const [data, setData] = useState<VehicleLog | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
if (!id) return;
router.replace(`/vehicle-management/vehicle-log/${id}?mode=edit`);
}, [id, router]);
getVehicleLogById(id)
.then((result) => {
if (result.success && result.data) {
setData(result.data);
} else {
setError(result.error || '데이터를 불러올 수 없습니다.');
}
})
.finally(() => setIsLoading(false));
}, [id]);
if (isLoading) return <GenericPageSkeleton />;
if (error || !data) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-red-500">{error || '차량일지를 찾을 수 없습니다.'}</div>
</div>
);
}
return <VehicleLogDetail mode="edit" initialData={data} id={id} />;
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
'use client';
/**
* 차량일지 상세 페이지
* 차량일지 상세/수정 페이지
*/
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useParams, useSearchParams } from 'next/navigation';
import { VehicleLogDetail } from '@/components/vehicle-management/VehicleLogDetail';
import { getVehicleLogById } from '@/components/vehicle-management/VehicleLogList/actions';
import type { VehicleLog } from '@/components/vehicle-management/types';
@@ -14,6 +14,8 @@ import { GenericPageSkeleton } from '@/components/ui/skeleton';
export default function VehicleLogDetailPage() {
const params = useParams();
const id = params.id as string;
const searchParams = useSearchParams();
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
const [data, setData] = useState<VehicleLog | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -43,5 +45,5 @@ export default function VehicleLogDetailPage() {
);
}
return <VehicleLogDetail mode="view" initialData={data} id={id} />;
return <VehicleLogDetail mode={mode} initialData={data} id={id} />;
}

View File

@@ -1,47 +1,24 @@
'use client';
/**
* 차량 수정 페이지
* 차량 수정 페이지 → 상세 페이지로 리다이렉트
*/
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { VehicleDetail } from '@/components/vehicle-management/VehicleDetail';
import { getVehicleById } from '@/components/vehicle-management/VehicleList/actions';
import type { Vehicle } from '@/components/vehicle-management/types';
import { GenericPageSkeleton } from '@/components/ui/skeleton';
import { useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
export default function VehicleEditPage() {
const params = useParams();
const id = params.id as string;
const [data, setData] = useState<Vehicle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
useEffect(() => {
if (!id) return;
router.replace(`/vehicle-management/vehicle/${id}?mode=edit`);
}, [id, router]);
getVehicleById(id)
.then((result) => {
if (result.success && result.data) {
setData(result.data);
} else {
setError(result.error || '데이터를 불러올 수 없습니다.');
}
})
.finally(() => setIsLoading(false));
}, [id]);
if (isLoading) return <GenericPageSkeleton />;
if (error || !data) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-red-500">{error || '차량을 찾을 수 없습니다.'}</div>
</div>
);
}
return <VehicleDetail mode="edit" initialData={data} id={id} />;
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-muted-foreground"> ...</div>
</div>
);
}

View File

@@ -1,11 +1,11 @@
'use client';
/**
* 차량 상세 페이지
* 차량 상세/수정 페이지
*/
import { useEffect, useState } from 'react';
import { useParams } from 'next/navigation';
import { useParams, useSearchParams } from 'next/navigation';
import { VehicleDetail } from '@/components/vehicle-management/VehicleDetail';
import { getVehicleById } from '@/components/vehicle-management/VehicleList/actions';
import type { Vehicle } from '@/components/vehicle-management/types';
@@ -14,6 +14,8 @@ import { GenericPageSkeleton } from '@/components/ui/skeleton';
export default function VehicleDetailPage() {
const params = useParams();
const id = params.id as string;
const searchParams = useSearchParams();
const mode = searchParams.get('mode') === 'edit' ? 'edit' : 'view';
const [data, setData] = useState<Vehicle | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -43,5 +45,5 @@ export default function VehicleDetailPage() {
);
}
return <VehicleDetail mode="view" initialData={data} id={id} />;
return <VehicleDetail mode={mode} initialData={data} id={id} />;
}

View File

@@ -12,6 +12,7 @@
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import {
FileText,
Plus,
@@ -107,8 +108,7 @@ export function BillManagementClient({
});
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-09-01');
const [endDate, setEndDate] = useState('2025-09-03');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// ===== API 데이터 로드 =====
const loadData = useCallback(async (page: number = 1) => {

View File

@@ -15,6 +15,8 @@ import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { getBills, deleteBill, updateBillStatus } from './actions';
import { useDateRange } from '@/hooks';
import { createDeleteItemHandler, extractUniqueOptions } from '../shared';
import {
FileText,
Plus,
@@ -84,8 +86,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
const [isSaving, setIsSaving] = useState(false);
// 날짜 범위
const [startDate, setStartDate] = useState('2025-09-01');
const [endDate, setEndDate] = useState('2025-09-03');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// 인라인 필터 상태
const [billTypeFilter, setBillTypeFilter] = useState<string>(initialBillType || 'received');
@@ -163,13 +164,10 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
}, [billData, sortOption]);
// 거래처 목록 (필터용)
const vendorOptions = useMemo(() => {
const uniqueVendors = [...new Set(billData.map(d => d.vendorName).filter(v => v))];
return [
{ value: 'all', label: '전체' },
...uniqueVendors.map(v => ({ value: v, label: v }))
];
}, [billData]);
const vendorOptions = useMemo(
() => extractUniqueOptions(billData, 'vendorName'),
[billData]
);
// ===== 핸들러 =====
const handleRowClick = useCallback((item: BillRecord) => {
@@ -245,14 +243,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
totalCount: pagination.total,
};
},
deleteItem: async (id: string) => {
const result = await deleteBill(id);
if (result.success) {
toast.success('어음이 삭제되었습니다.');
setBillData(prev => prev.filter(item => item.id !== id));
}
return { success: result.success, error: result.error };
},
deleteItem: createDeleteItemHandler(deleteBill, setBillData, '어음이 삭제되었습니다.'),
},
// 테이블 컬럼

View File

@@ -18,6 +18,7 @@ import {
import { DatePicker } from '@/components/ui/date-picker';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import { Badge } from '@/components/ui/badge';
import type { NoteReceivableItem, DailyAccountItem } from './types';
import { MATCH_STATUS_LABELS, MATCH_STATUS_COLORS } from './types';
@@ -95,12 +96,6 @@ export function DailyReport({ initialNoteReceivables = [], initialDailyAccounts
}
}, [selectedDate, loadData]);
// ===== 금액 포맷 =====
const formatAmount = useCallback((amount: number) => {
if (amount === 0) return '0';
return amount.toLocaleString();
}, []);
// ===== 어음 합계 (API 요약 사용) =====
const noteReceivableTotal = useMemo(() => {
return summary?.noteReceivableTotal ?? noteReceivables.reduce((sum, item) => sum + item.currentBalance, 0);

View File

@@ -71,6 +71,14 @@ import {
} from './types';
import { deleteDeposit, updateDepositTypes, getDeposits } from './actions';
import { toast } from 'sonner';
import { useDateRange } from '@/hooks';
import {
createDeleteItemHandler,
extractUniqueOptions,
createDateAmountSortFn,
computeMonthlyTotal,
type SortDirection,
} from '../shared';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
@@ -98,8 +106,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
const router = useRouter();
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [startDate, setStartDate] = useState('2025-09-01');
const [endDate, setEndDate] = useState('2025-09-03');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
const [depositData, setDepositData] = useState<DepositRecord[]>(initialData);
const [isRefreshing, setIsRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -120,27 +127,17 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
// ===== 통계 계산 =====
const stats = useMemo(() => {
const totalDeposit = depositData.reduce((sum, d) => sum + (d.depositAmount ?? 0), 0);
const currentMonth = new Date().getMonth();
const currentYear = new Date().getFullYear();
const monthlyDeposit = depositData
.filter(d => {
const date = new Date(d.depositDate);
return date.getMonth() === currentMonth && date.getFullYear() === currentYear;
})
.reduce((sum, d) => sum + (d.depositAmount ?? 0), 0);
const monthlyDeposit = computeMonthlyTotal(depositData, 'depositDate', 'depositAmount');
const vendorUnsetCount = depositData.filter(d => !d.vendorName).length;
const depositTypeUnsetCount = depositData.filter(d => d.depositType === 'unset').length;
return { totalDeposit, monthlyDeposit, vendorUnsetCount, depositTypeUnsetCount };
}, [depositData]);
// ===== 거래처 목록 (필터용) =====
const vendorOptions = useMemo(() => {
const uniqueVendors = [...new Set(depositData.map(d => d.vendorName).filter(v => v))];
return [
{ value: 'all', label: '전체' },
...uniqueVendors.map(v => ({ value: v, label: v }))
];
}, [depositData]);
const vendorOptions = useMemo(
() => extractUniqueOptions(depositData, 'vendorName'),
[depositData]
);
// ===== 핸들러 =====
const handleRowClick = useCallback((item: DepositRecord) => {
@@ -224,14 +221,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
totalCount: initialData.length,
};
},
deleteItem: async (id: string) => {
const result = await deleteDeposit(id);
if (result.success) {
setDepositData(prev => prev.filter(item => item.id !== id));
toast.success('입금 내역이 삭제되었습니다.');
}
return { success: result.success, error: result.error };
},
deleteItem: createDeleteItemHandler(deleteDeposit, setDepositData, '입금 내역이 삭제되었습니다.'),
},
// 테이블 컬럼
@@ -284,24 +274,8 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
},
// 커스텀 정렬 함수
customSortFn: (items, filterValues) => {
const sorted = [...items];
switch (sortOption) {
case 'oldest':
sorted.sort((a, b) => new Date(a.depositDate).getTime() - new Date(b.depositDate).getTime());
break;
case 'amountHigh':
sorted.sort((a, b) => (b.depositAmount ?? 0) - (a.depositAmount ?? 0));
break;
case 'amountLow':
sorted.sort((a, b) => (a.depositAmount ?? 0) - (b.depositAmount ?? 0));
break;
default: // latest
sorted.sort((a, b) => new Date(b.depositDate).getTime() - new Date(a.depositDate).getTime());
break;
}
return sorted;
},
customSortFn: (items) =>
createDateAmountSortFn<DepositRecord>('depositDate', 'depositAmount', sortOption as SortDirection)(items),
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,

View File

@@ -90,6 +90,7 @@ import {
PAYMENT_STATUS_FILTER_OPTIONS,
ACCOUNT_SUBJECT_OPTIONS,
} from './types';
import { extractUniqueOptions } from '../shared';
// ===== 테이블 행 타입 (데이터 + 그룹 헤더 + 소계) =====
type RowType = 'data' | 'monthHeader' | 'monthSubtotal' | 'totalExpense' | 'expectedBalance' | 'finalBalance';
@@ -296,13 +297,10 @@ export function ExpectedExpenseManagement({
}, []);
// ===== 거래처 필터 옵션 (데이터에서 동적 추출) =====
const vendorFilterOptions = useMemo(() => {
const vendors = [...new Set(data.map(item => item.vendorName).filter(Boolean))];
return [
{ value: 'all', label: '전체' },
...vendors.map(vendor => ({ value: vendor, label: vendor }))
];
}, [data]);
const vendorFilterOptions = useMemo(
() => extractUniqueOptions(data, 'vendorName'),
[data]
);
// ===== 필터링된 원본 데이터 =====
const filteredRawData = useMemo(() => {

View File

@@ -12,7 +12,6 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import {
Gift, PlusCircle,
} from 'lucide-react';
@@ -47,6 +46,8 @@ import {
getGiftCertificateSummary,
} from './actions';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import { useDateRange } from '@/hooks';
// ===== 테이블 컬럼 정의 (체크박스/No. 제외) =====
const tableColumns = [
@@ -86,10 +87,7 @@ export function GiftCertificateManagement() {
const [searchQuery, setSearchQuery] = useState('');
// 날짜 범위
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
const formatAmount = (amount: number) => amount.toLocaleString('ko-KR');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth');
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {

View File

@@ -42,6 +42,7 @@ import {
} from './actions';
import { getClients } from '../VendorManagement/actions';
import { toast } from 'sonner';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
interface PurchaseDetailProps {
purchaseId: string;
@@ -147,11 +148,6 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
};
}, [items]);
// ===== 금액 포맷 =====
const formatAmount = (amount: number): string => {
return amount.toLocaleString();
};
// ===== 핸들러 =====
const handleVendorChange = useCallback((clientId: string) => {
const client = clients.find(c => c.id === clientId);

View File

@@ -16,6 +16,7 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import { toast } from 'sonner';
import {
Receipt,
@@ -81,8 +82,7 @@ export function PurchaseManagement() {
const router = useRouter();
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [startDate, setStartDate] = useState('2025-01-01');
const [endDate, setEndDate] = useState('2025-12-31');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
const [purchaseData, setPurchaseData] = useState<PurchaseRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const isInitialLoadDone = useRef(false);

View File

@@ -48,6 +48,7 @@ import { SALES_TYPE_OPTIONS } from './types';
import { getSaleById, createSale, updateSale, deleteSale } from './actions';
import { toast } from 'sonner';
import { getClients } from '../VendorManagement/actions';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
// ===== Props =====
interface SalesDetailProps {
@@ -265,11 +266,6 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
}
}, [selectedVendor]);
// ===== 금액 포맷 =====
const formatAmount = (amount: number): string => {
return amount.toLocaleString();
};
// ===== 폼 내용 렌더링 =====
const renderFormContent = () => (
<>

View File

@@ -63,6 +63,14 @@ import {
ACCOUNT_SUBJECT_SELECTOR_OPTIONS,
} from './types';
import { getSales, deleteSale, toggleSaleIssuance } from './actions';
import { useDateRange } from '@/hooks';
import {
createDeleteItemHandler,
extractUniqueOptions,
createDateAmountSortFn,
computeMonthlyTotal,
type SortDirection,
} from '../shared';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
@@ -93,9 +101,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
const router = useRouter();
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const currentYear = new Date().getFullYear();
const [startDate, setStartDate] = useState(`${currentYear}-01-01`);
const [endDate, setEndDate] = useState(`${currentYear}-12-31`);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
const [salesData, setSalesData] = useState<SalesRecord[]>(initialData || []);
const [pagination, setPagination] = useState(initialPagination);
const [currentPage, setCurrentPage] = useState(initialPagination.currentPage);
@@ -118,24 +124,17 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
// ===== 통계 계산 =====
const stats = useMemo(() => {
const totalSalesAmount = salesData.reduce((sum, d) => sum + d.totalAmount, 0);
const currentMonth = new Date().getMonth();
const currentYear = new Date().getFullYear();
const monthlyAmount = salesData
.filter(d => {
const date = new Date(d.salesDate);
return date.getMonth() === currentMonth && date.getFullYear() === currentYear;
})
.reduce((sum, d) => sum + d.totalAmount, 0);
const monthlyAmount = computeMonthlyTotal(salesData, 'salesDate', 'totalAmount');
const taxInvoicePendingCount = salesData.filter(d => !d.taxInvoiceIssued).length;
const transactionStatementPendingCount = salesData.filter(d => !d.transactionStatementIssued).length;
return { totalSalesAmount, monthlyAmount, taxInvoicePendingCount, transactionStatementPendingCount };
}, [salesData]);
// ===== 거래처 목록 (필터용) =====
const vendorOptions = useMemo(() => {
const uniqueVendors = [...new Set(salesData.map(d => d.vendorName))].filter(v => v && v.trim() !== '');
return uniqueVendors.map(v => ({ value: v, label: v }));
}, [salesData]);
const vendorOptions = useMemo(
() => extractUniqueOptions(salesData, 'vendorName', { includeAll: false }),
[salesData]
);
// ===== filterConfig 정의 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
@@ -305,14 +304,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
}
return { success: true, data: salesData, totalCount: salesData.length };
},
deleteItem: async (id: string) => {
const result = await deleteSale(id);
if (result.success) {
setSalesData(prev => prev.filter(item => item.id !== id));
toast.success('매출이 삭제되었습니다.');
}
return { success: result.success, error: result.error };
},
deleteItem: createDeleteItemHandler(deleteSale, setSalesData, '매출이 삭제되었습니다.'),
},
// 테이블 컬럼
@@ -374,25 +366,8 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
},
// 커스텀 정렬 함수
customSortFn: (items, fv) => {
const sorted = [...items];
const sortVal = fv.sort as string;
switch (sortVal) {
case 'oldest':
sorted.sort((a, b) => new Date(a.salesDate).getTime() - new Date(b.salesDate).getTime());
break;
case 'amountHigh':
sorted.sort((a, b) => b.totalAmount - a.totalAmount);
break;
case 'amountLow':
sorted.sort((a, b) => a.totalAmount - b.totalAmount);
break;
default: // latest
sorted.sort((a, b) => new Date(b.salesDate).getTime() - new Date(a.salesDate).getTime());
break;
}
return sorted;
},
customSortFn: (items, fv) =>
createDateAmountSortFn<SalesRecord>('salesDate', 'totalAmount', (fv.sort as SortDirection) ?? 'latest')(items),
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,

View File

@@ -30,6 +30,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { getTodayString, getLocalDateString } from '@/lib/utils/date';
import { getCardHistory } from './actions';
import type { CardHistoryRecord } from './types';
@@ -44,10 +45,8 @@ export function CardHistoryModal({
onOpenChange,
onSelect,
}: CardHistoryModalProps) {
const today = new Date().toISOString().split('T')[0];
const monthAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
.toISOString()
.split('T')[0];
const today = getTodayString();
const monthAgo = getLocalDateString(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000));
const [startDate, setStartDate] = useState(monthAgo);
const [endDate, setEndDate] = useState(today);

View File

@@ -32,6 +32,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { getTodayString } from '@/lib/utils/date';
import { createTaxInvoice } from './actions';
import { CardHistoryModal } from './CardHistoryModal';
import type { InvoiceTab, TaxType, ManualEntryFormData, CardHistoryRecord } from './types';
@@ -46,7 +47,7 @@ interface ManualEntryModalProps {
const initialFormData: ManualEntryFormData = {
division: 'sales',
writeDate: new Date().toISOString().split('T')[0],
writeDate: getTodayString(),
vendorName: '',
vendorBusinessNumber: '',
supplyAmount: 0,

View File

@@ -70,6 +70,14 @@ import {
} from './types';
import { deleteWithdrawal, updateWithdrawalTypes, getWithdrawals } from './actions';
import { toast } from 'sonner';
import { useDateRange } from '@/hooks';
import {
createDeleteItemHandler,
extractUniqueOptions,
createDateAmountSortFn,
computeMonthlyTotal,
type SortDirection,
} from '../shared';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
@@ -100,8 +108,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
const [withdrawalData, setWithdrawalData] = useState<WithdrawalRecord[]>(initialData);
// 날짜 범위
const [startDate, setStartDate] = useState('2025-09-01');
const [endDate, setEndDate] = useState('2025-09-03');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// 인라인 필터 상태
const [vendorFilter, setVendorFilter] = useState<string>('all');
@@ -125,16 +132,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
// ===== Stats 계산 =====
const stats = useMemo(() => {
const totalWithdrawal = withdrawalData.reduce((sum, d) => sum + (d.withdrawalAmount ?? 0), 0);
// 당월 출금
const currentMonth = new Date().getMonth();
const currentYear = new Date().getFullYear();
const monthlyWithdrawal = withdrawalData
.filter(d => {
const date = new Date(d.withdrawalDate);
return date.getMonth() === currentMonth && date.getFullYear() === currentYear;
})
.reduce((sum, d) => sum + (d.withdrawalAmount ?? 0), 0);
const monthlyWithdrawal = computeMonthlyTotal(withdrawalData, 'withdrawalDate', 'withdrawalAmount');
// 거래처 미설정 건수
const vendorUnsetCount = withdrawalData.filter(d => !d.vendorName).length;
@@ -146,13 +144,10 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
}, [withdrawalData]);
// 거래처 목록 (필터용)
const vendorOptions = useMemo(() => {
const uniqueVendors = [...new Set(withdrawalData.map(d => d.vendorName).filter(v => v))];
return [
{ value: 'all', label: '전체' },
...uniqueVendors.map(v => ({ value: v, label: v }))
];
}, [withdrawalData]);
const vendorOptions = useMemo(
() => extractUniqueOptions(withdrawalData, 'vendorName'),
[withdrawalData]
);
// ===== 테이블 합계 계산 =====
const tableTotals = useMemo(() => {
@@ -240,14 +235,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
totalCount: initialData.length,
};
},
deleteItem: async (id: string) => {
const result = await deleteWithdrawal(id);
if (result.success) {
toast.success('출금 내역이 삭제되었습니다.');
setWithdrawalData(prev => prev.filter(item => item.id !== id));
}
return { success: result.success, error: result.error };
},
deleteItem: createDeleteItemHandler(deleteWithdrawal, setWithdrawalData, '출금 내역이 삭제되었습니다.'),
},
// 테이블 컬럼
@@ -331,24 +319,8 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
},
// 커스텀 정렬 함수
customSortFn: (items) => {
const sorted = [...items];
switch (sortOption) {
case 'oldest':
sorted.sort((a, b) => new Date(a.withdrawalDate).getTime() - new Date(b.withdrawalDate).getTime());
break;
case 'amountHigh':
sorted.sort((a, b) => b.withdrawalAmount - a.withdrawalAmount);
break;
case 'amountLow':
sorted.sort((a, b) => a.withdrawalAmount - b.withdrawalAmount);
break;
default: // latest
sorted.sort((a, b) => new Date(b.withdrawalDate).getTime() - new Date(a.withdrawalDate).getTime());
break;
}
return sorted;
},
customSortFn: (items) =>
createDateAmountSortFn<WithdrawalRecord>('withdrawalDate', 'withdrawalAmount', sortOption as SortDirection)(items),
// 검색창 (공통 컴포넌트에서 자동 생성)
searchValue: searchQuery,

View File

@@ -0,0 +1,131 @@
/**
* 회계 컴포넌트 공통 패턴
*
* SalesManagement, DepositManagement, WithdrawalManagement, BillManagement 등
* 회계 목록 컴포넌트에서 반복되는 로직을 추출한 유틸리티 모음
*/
import { toast } from 'sonner';
// ===== 1. 삭제 핸들러 팩토리 (4파일 동일) =====
/**
* UniversalListPage의 deleteItem 핸들러를 생성합니다.
*
* @example
* deleteItem: createDeleteItemHandler(deleteSale, setSalesData, '매출이 삭제되었습니다.'),
*/
export function createDeleteItemHandler<T extends { id: string }>(
deleteAction: (id: string) => Promise<{ success: boolean; error?: string }>,
setData: React.Dispatch<React.SetStateAction<T[]>>,
successMessage: string
) {
return async (id: string) => {
const result = await deleteAction(id);
if (result.success) {
setData(prev => prev.filter(item => item.id !== id));
toast.success(successMessage);
}
return { success: result.success, error: result.error };
};
}
// ===== 2. 거래처 옵션 추출 (5파일 동일) =====
/**
* 데이터 배열에서 고유한 값을 추출하여 Select 옵션으로 변환합니다.
*
* @example
* const vendorOptions = useMemo(() => extractUniqueOptions(data, 'vendorName'), [data]);
*/
export function extractUniqueOptions<T>(
data: T[],
fieldKey: keyof T,
options?: { includeAll?: boolean; allLabel?: string }
): { value: string; label: string }[] {
const { includeAll = true, allLabel = '전체' } = options ?? {};
const uniqueValues = [...new Set(
data
.map(item => {
const val = item[fieldKey];
return typeof val === 'string' ? val.trim() : '';
})
.filter(v => v !== '')
)];
const items = uniqueValues.map(v => ({ value: v, label: v }));
if (includeAll) {
return [{ value: 'all', label: allLabel }, ...items];
}
return items;
}
// ===== 3. 날짜+금액 정렬 팩토리 (5파일 유사) =====
export type SortDirection = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
/**
* 날짜+금액 기준 정렬 함수를 생성합니다.
*
* @example
* customSortFn: (items) => createDateAmountSortFn('salesDate', 'totalAmount', sortOption)(items),
*/
export function createDateAmountSortFn<T>(
dateField: keyof T,
amountField: keyof T,
direction: SortDirection
): (items: T[]) => T[] {
return (items: T[]) => {
const sorted = [...items];
switch (direction) {
case 'oldest':
sorted.sort((a, b) =>
new Date(String(a[dateField])).getTime() - new Date(String(b[dateField])).getTime()
);
break;
case 'amountHigh':
sorted.sort((a, b) =>
((b[amountField] as number) ?? 0) - ((a[amountField] as number) ?? 0)
);
break;
case 'amountLow':
sorted.sort((a, b) =>
((a[amountField] as number) ?? 0) - ((b[amountField] as number) ?? 0)
);
break;
default: // latest
sorted.sort((a, b) =>
new Date(String(b[dateField])).getTime() - new Date(String(a[dateField])).getTime()
);
break;
}
return sorted;
};
}
// ===== 4. 당월 합계 계산 (3파일 동일) =====
/**
* 데이터 배열에서 현재 월에 해당하는 금액 합계를 계산합니다.
*
* @example
* const monthlyAmount = computeMonthlyTotal(data, 'salesDate', 'totalAmount');
*/
export function computeMonthlyTotal<T>(
data: T[],
dateField: keyof T,
amountField: keyof T
): number {
const now = new Date();
const currentMonth = now.getMonth();
const currentYear = now.getFullYear();
return data
.filter(item => {
const date = new Date(String(item[dateField]));
return date.getMonth() === currentMonth && date.getFullYear() === currentYear;
})
.reduce((sum, item) => sum + (((item[amountField]) as number) ?? 0), 0);
}

View File

@@ -2,6 +2,7 @@
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import {
FileCheck,
Check,
@@ -98,8 +99,7 @@ export function ApprovalBox() {
const itemsPerPage = 20;
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-09-01');
const [endDate, setEndDate] = useState('2025-09-03');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// 다이얼로그 상태
const [approveDialogOpen, setApproveDialogOpen] = useState(false);

View File

@@ -12,6 +12,7 @@ import {
TableRow,
} from '@/components/ui/table';
import type { ExpenseEstimateData, ExpenseEstimateItem } from './types';
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
interface ExpenseEstimateFormProps {
data: ExpenseEstimateData;
@@ -22,10 +23,6 @@ interface ExpenseEstimateFormProps {
export function ExpenseEstimateForm({ data, onChange, isLoading }: ExpenseEstimateFormProps) {
const items = data.items;
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ko-KR').format(amount);
};
const handleCheckChange = (id: string, checked: boolean) => {
const newItems = items.map((item) =>
item.id === id ? { ...item, checked } : item

View File

@@ -25,6 +25,7 @@ import {
} from '@/components/ui/table';
import type { ExpenseReportData, ExpenseReportItem } from './types';
import { CARD_OPTIONS } from './types';
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
interface ExpenseReportFormProps {
data: ExpenseReportData;
@@ -71,10 +72,6 @@ export function ExpenseReportForm({ data, onChange }: ExpenseReportFormProps) {
onChange({ ...data, attachments: updatedAttachments });
};
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ko-KR').format(amount);
};
return (
<div className="space-y-6">
{/* 지출 정보 */}

View File

@@ -11,15 +11,13 @@ import { Fragment } from 'react';
import { ApprovalLineBox } from './ApprovalLineBox';
import type { ExpenseEstimateDocumentData } from './types';
import { DocumentHeader } from '@/components/document-system';
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
interface ExpenseEstimateDocumentProps {
data: ExpenseEstimateDocumentData;
}
export function ExpenseEstimateDocument({ data }: ExpenseEstimateDocumentProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ko-KR').format(amount);
};
// 월별 그룹핑
const groupedByMonth = data.items.reduce((acc, item) => {

View File

@@ -10,15 +10,13 @@
import { ApprovalLineBox } from './ApprovalLineBox';
import type { ExpenseReportDocumentData } from './types';
import { DocumentHeader } from '@/components/document-system';
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
interface ExpenseReportDocumentProps {
data: ExpenseReportDocumentData;
}
export function ExpenseReportDocument({ data }: ExpenseReportDocumentProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ko-KR').format(amount);
};
return (
<div className="bg-white p-8 min-h-full">

View File

@@ -10,15 +10,13 @@
import { ApprovalLineBox } from './ApprovalLineBox';
import type { ProposalDocumentData } from './types';
import { DocumentHeader } from '@/components/document-system';
import { formatNumber as formatCurrency } from '@/lib/utils/amount';
interface ProposalDocumentProps {
data: ProposalDocumentData;
}
export function ProposalDocument({ data }: ProposalDocumentProps) {
const formatCurrency = (amount: number) => {
return new Intl.NumberFormat('ko-KR').format(amount);
};
return (
<div className="bg-white p-8 min-h-full">

View File

@@ -2,6 +2,7 @@
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import {
FileText,
Send,
@@ -81,8 +82,7 @@ export function DraftBox() {
const itemsPerPage = 20;
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-01-01');
const [endDate, setEndDate] = useState('2025-12-31');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// API 데이터
const [data, setData] = useState<DraftRecord[]>([]);

View File

@@ -1,6 +1,7 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useTransition, useRef } from 'react';
import { useDateRange } from '@/hooks';
import {
Files,
Eye,
@@ -74,8 +75,7 @@ export function ReferenceBox() {
const itemsPerPage = 20;
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-09-01');
const [endDate, setEndDate] = useState('2025-09-03');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// 다이얼로그 상태
const [markReadDialogOpen, setMarkReadDialogOpen] = useState(false);

View File

@@ -35,6 +35,7 @@ import {
import { getHandoverReportList, getHandoverReportStats } from './actions';
import { formatNumber } from '@/lib/utils/amount';
import { formatDateRange } from '@/lib/utils/date';
import { useDateRange } from '@/hooks';
// 테이블 컬럼 정의
const tableColumns = [
@@ -89,8 +90,7 @@ export default function HandoverReportListClient({
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const [searchQuery, setSearchQuery] = useState('');
const [stats, setStats] = useState<HandoverReportStats | null>(initialStats || null);

View File

@@ -49,6 +49,7 @@ import {
withdrawIssues,
} from './actions';
import { formatDate } from '@/lib/utils/date';
import { useDateRange } from '@/hooks';
// 테이블 컬럼 정의
const tableColumns = [
@@ -84,8 +85,7 @@ export default function IssueManagementListClient({
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved'>('all');
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);

View File

@@ -51,6 +51,8 @@ import {
getConstructionManagementList,
getConstructionManagementStats,
} from './actions';
import { formatDate } from '@/lib/utils/date';
import { useDateRange } from '@/hooks';
// 테이블 컬럼 정의
const tableColumns = [
@@ -83,8 +85,7 @@ export default function ConstructionManagementListClient({
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all');
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const { data: stats } = useStatsLoader(getConstructionManagementStats, initialStats);
const [searchQuery, setSearchQuery] = useState('');
@@ -156,12 +157,6 @@ export default function ConstructionManagementListClient({
// 달력 뱃지 (사용 안 함)
const calendarBadges: DayBadge[] = [];
// 날짜 포맷
const formatDate = useCallback((dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
}, []);
// 달력 이벤트 핸들러
const handleCalendarDateClick = useCallback((date: Date) => {
if (selectedCalendarDate && isSameDay(selectedCalendarDate, date)) {

View File

@@ -5,6 +5,7 @@ import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { cn } from '@/lib/utils';
import type { ProjectDetail, ProjectStatus } from './types';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
interface ProjectCardProps {
project: ProjectDetail;
@@ -28,11 +29,6 @@ const ProjectCard = memo(function ProjectCard({ project, isSelected, onClick }:
}
};
// 금액 포맷
const formatAmount = (amount: number) => {
return amount.toLocaleString();
};
return (
<div
className={cn(

View File

@@ -18,6 +18,7 @@ import {
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { MobileCard } from '@/components/organisms/MobileCard';
import { formatAmountWon as formatAmount } from '@/lib/utils/amount';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import type { Project, ProjectStats, ChartViewMode, SelectOption } from './types';
@@ -240,11 +241,6 @@ export default function ProjectListClient({ initialData = [], initialStats }: Pr
}, [selectedItems.size, paginatedData]);
// 금액 포맷
const formatAmount = (amount: number) => {
return amount.toLocaleString() + '원';
};
// 날짜 포맷
const formatDate = (dateStr: string) => {
return dateStr.replace(/-/g, '.');

View File

@@ -6,6 +6,7 @@ import { getPresetStyle } from '@/lib/utils/status-config';
import { cn } from '@/lib/utils';
import type { Stage, StageCardStatus } from './types';
import { STAGE_LABELS, STAGE_CARD_STATUS_LABELS } from './types';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
interface StageCardProps {
stage: Stage;
@@ -28,11 +29,6 @@ const StageCard = memo(function StageCard({ stage, isSelected, onClick }: StageC
}
};
// 금액 포맷
const formatAmount = (amount: number) => {
return amount.toLocaleString();
};
return (
<div
className={cn(

View File

@@ -45,12 +45,14 @@ import {
MOCK_WORK_TEAM_LEADERS,
getScheduleColorByManager,
} from './types';
import { formatDate } from '@/lib/utils/date';
import {
getOrderList,
getOrderStats,
deleteOrder,
deleteOrders,
} from './actions';
import { useDateRange } from '@/hooks';
// 테이블 컬럼 정의
const tableColumns = [
@@ -90,8 +92,7 @@ export default function OrderManagementListClient({
);
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const [searchQuery, setSearchQuery] = useState('');
// 달력 관련 상태
@@ -164,12 +165,6 @@ export default function OrderManagementListClient({
// 달력 뱃지 (사용 안 함)
const calendarBadges: DayBadge[] = [];
// 날짜 포맷
const formatDate = useCallback((dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
}, []);
// ===== 추가 핸들러 =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/order/order-management?mode=new');

View File

@@ -50,6 +50,7 @@ import {
deleteOrder,
deleteOrders,
} from './actions';
import { formatDate } from '@/lib/utils/date';
interface OrderManagementUnifiedProps {
initialData?: Order[];
@@ -163,12 +164,6 @@ export function OrderManagementUnified({ initialData }: OrderManagementUnifiedPr
setCalendarDate(date);
}, []);
// 날짜 포맷
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return dateStr.split('T')[0];
};
// 달력 필터 슬롯
const calendarFilterSlot = (
<div className="flex items-center gap-2">

View File

@@ -44,6 +44,7 @@ import {
deletePricing,
deletePricings,
} from './actions';
import { useDateRange } from '@/hooks';
interface PricingListClientProps {
initialData?: Pricing[];
@@ -61,8 +62,7 @@ export default function PricingListClient({
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_use' | 'not_registered'>('all');
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const [stats, setStats] = useState<PricingStats | null>(initialStats || null);
const [pricingData, setPricingData] = useState<Pricing[]>(initialData);

View File

@@ -39,6 +39,7 @@ import {
getProgressBillingList,
getProgressBillingStats,
} from './actions';
import { useDateRange } from '@/hooks';
// 테이블 컬럼 정의
const tableColumns = [
@@ -76,8 +77,7 @@ export default function ProgressBillingManagementListClient({
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const { data: stats } = useStatsLoader(getProgressBillingStats, initialStats);
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -9,11 +9,7 @@
import type { ProgressBillingDetailFormData } from '../types';
import { DocumentHeader } from '@/components/document-system';
function formatNumber(num: number | undefined): string {
if (num === undefined || num === null) return '-';
return num.toLocaleString('ko-KR');
}
import { formatNumber } from '@/lib/utils/amount';
interface DirectConstructionItem {
id: string;

View File

@@ -9,12 +9,7 @@
import type { ProgressBillingDetailFormData } from '../types';
import { DocumentHeader } from '@/components/document-system';
// 숫자 포맷팅 (천단위 콤마)
function formatNumber(num: number | undefined): string {
if (num === undefined || num === null) return '-';
return num.toLocaleString('ko-KR');
}
import { formatNumber } from '@/lib/utils/amount';
// 간접 공사 내역 아이템 타입
interface IndirectConstructionItem {

View File

@@ -33,6 +33,7 @@ import {
SITE_STATUS_LABELS,
} from './types';
import { getSiteList, getSiteStats, deleteSite, deleteSites } from './actions';
import { useDateRange } from '@/hooks';
// 테이블 컬럼 정의
const tableColumns = [
@@ -66,8 +67,7 @@ export default function SiteManagementListClient({
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'construction' | 'unregistered'>('all');
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const [stats, setStats] = useState<SiteStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -39,6 +39,7 @@ import {
deleteStructureReviews,
} from './actions';
import { formatDate } from '@/lib/utils/date';
import { useDateRange } from '@/hooks';
// 테이블 컬럼 정의
const tableColumns = [
@@ -77,8 +78,7 @@ export default function StructureReviewListClient({
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const [stats, setStats] = useState<StructureReviewStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -44,6 +44,7 @@ import {
} from './actions';
import { formatNumber } from '@/lib/utils/amount';
import { formatDate } from '@/lib/utils/date';
import { useDateRange } from '@/hooks';
// 테이블 컬럼 정의
const tableColumns = [
@@ -72,8 +73,7 @@ export default function UtilityManagementListClient({
}: UtilityManagementListClientProps) {
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'complete'>('all');
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -39,6 +39,7 @@ import {
MOCK_WORKER_NAMES,
} from './types';
import { getWorkerStatusList, getWorkerStatusStats } from './actions';
import { useDateRange } from '@/hooks';
// 테이블 컬럼 정의
const tableColumns = [
@@ -82,8 +83,7 @@ export default function WorkerStatusListClient({
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'all_contract' | 'pending' | 'completed'>('all');
const [startDate, setStartDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(() => new Date().toISOString().split('T')[0]);
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('today');
const [stats, setStats] = useState<WorkerStatusStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -11,6 +11,7 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import { ClipboardList, Plus, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow, TableHead } from '@/components/ui/table';
@@ -47,8 +48,7 @@ export default function ChecklistListClient() {
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-01-01');
const [endDate, setEndDate] = useState('2025-12-31');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// 검색어 상태
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -1,11 +1,11 @@
'use client';
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useDateRange } from '@/hooks';
import {
DollarSign,
Check,
Clock,
Pencil,
Banknote,
Briefcase,
Timer,
@@ -61,8 +61,7 @@ export function SalaryManagement() {
const itemsPerPage = 20;
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-12-01');
const [endDate, setEndDate] = useState('2025-12-31');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth');
// 다이얼로그 상태
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
@@ -317,7 +316,6 @@ export function SalaryManagement() {
{ key: 'netPayment', label: '실지급액', className: 'text-right' },
{ key: 'paymentDate', label: '일자', className: 'text-center' },
{ key: 'status', label: '상태', className: 'text-center' },
{ key: 'action', label: '작업', className: 'text-center w-[80px]' },
], []);
// ===== filterConfig 기반 통합 필터 시스템 =====
@@ -453,8 +451,12 @@ export function SalaryManagement() {
const { isSelected, onToggle } = handlers;
return (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="text-center">
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleViewDetail(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell>{item.department}</TableCell>
@@ -473,17 +475,6 @@ export function SalaryManagement() {
{PAYMENT_STATUS_LABELS[item.status]}
</Badge>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleViewDetail(item)}
title="수정"
disabled={isActionLoading}
>
<Pencil className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
);
},
@@ -502,6 +493,7 @@ export function SalaryManagement() {
}
isSelected={isSelected}
onToggleSelection={onToggle}
onClick={() => handleViewDetail(item)}
infoGrid={
<div className="grid grid-cols-2 gap-3">
<InfoField label="부서" value={item.department} />
@@ -515,18 +507,6 @@ export function SalaryManagement() {
<InfoField label="지급일" value={item.paymentDate} />
</div>
}
actions={
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => handleViewDetail(item)}
disabled={isActionLoading}
>
<Pencil className="h-4 w-4 mr-2" />
</Button>
}
/>
);
},

View File

@@ -2,6 +2,7 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { format } from 'date-fns';
import { useDateRange } from '@/hooks';
import {
Plus,
Calendar,
@@ -103,8 +104,7 @@ export function VacationManagement() {
const itemsPerPage = 20;
// 날짜 범위 상태 (input type="date" 용)
const [startDate, setStartDate] = useState('2025-12-01');
const [endDate, setEndDate] = useState('2025-12-31');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentMonth');
// 다이얼로그 상태
const [grantDialogOpen, setGrantDialogOpen] = useState(false);

View File

@@ -17,6 +17,7 @@
import { useState, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Upload, FileText, Search, X, Plus, ClipboardCheck } from 'lucide-react';
import { getTodayString } from '@/lib/utils/date';
import { FileDropzone } from '@/components/ui/file-dropzone';
import { ItemSearchModal } from '@/components/quotes/ItemSearchModal';
import { InspectionModal } from '@/app/[locale]/(protected)/quality/qms/components/InspectionModal';
@@ -311,7 +312,7 @@ export function ReceivingDetail({ id, mode = 'view' }: Props) {
const handleAddAdjustment = () => {
const newRecord: InventoryAdjustmentRecord = {
id: `adj-${Date.now()}`,
adjustmentDate: new Date().toISOString().split('T')[0],
adjustmentDate: getTodayString(),
quantity: 0,
inspector: getLoggedInUserName() || '홍길동',
};

View File

@@ -13,6 +13,7 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { getLocalDateString } from '@/lib/utils/date';
import {
Package,
CheckCircle2,
@@ -89,8 +90,8 @@ export function ReceivingList() {
// ===== 날짜 범위 상태 (최근 30일) =====
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
const [startDate, setStartDate] = useState<string>(thirtyDaysAgo.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
const [startDate, setStartDate] = useState<string>(getLocalDateString(thirtyDaysAgo));
const [endDate, setEndDate] = useState<string>(getLocalDateString(today));
// ===== 필터 상태 =====
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({

View File

@@ -22,6 +22,7 @@ import { buildApiUrl } from '@/lib/api/query-params';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { executeServerAction } from '@/lib/api/execute-server-action';
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
import { getTodayString } from '@/lib/utils/date';
import type {
ReceivingItem,
@@ -1074,7 +1075,7 @@ export async function getInspectionTemplate(params: {
lotNo: params.lotNo || '250715-02',
inspectionDate: new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', ''),
inspector: inspectorName,
reportDate: new Date().toISOString().split('T')[0],
reportDate: getTodayString(),
approvers: {
writer: inspectorName,
reviewer: '',
@@ -1653,7 +1654,7 @@ function transformResolveToTemplate(
lotNo: params.lotNo || '',
inspectionDate: new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', ''),
inspector: params.inspector || '',
reportDate: new Date().toISOString().split('T')[0],
reportDate: getTodayString(),
approvers: {
// 작성자는 현재 검사자 (로그인 사용자)로 설정
writer: params.inspector || writerLine?.role || '',
@@ -1888,7 +1889,7 @@ export async function saveInspectionData(params: {
if (!docResult.success) return { success: false, error: docResult.error };
// Step 2: PUT /v1/receivings/{id} - 검사 완료 후 입고대기로 상태 변경 (비필수)
const today = new Date().toISOString().split('T')[0];
const today = getTodayString();
const inspectionStatus = params.inspectionResult === 'pass' ? '적' : params.inspectionResult === 'fail' ? '부적' : '-';
const inspectionResultLabel = params.inspectionResult === 'pass' ? '합격' : params.inspectionResult === 'fail' ? '불합격' : null;

View File

@@ -35,6 +35,7 @@ import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { getStocks, getStockStats } from './actions';
import { USE_STATUS_LABELS, ITEM_TYPE_LABELS } from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { getLocalDateString } from '@/lib/utils/date';
import type { StockItem, StockStats, ItemType, StockStatusType } from './types';
// 페이지당 항목 수
@@ -49,8 +50,8 @@ export function StockStatusList() {
// ===== 날짜 범위 상태 =====
const today = new Date();
const firstDayOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
const [startDate, setStartDate] = useState<string>(firstDayOfMonth.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
const [startDate, setStartDate] = useState<string>(getLocalDateString(firstDayOfMonth));
const [endDate, setEndDate] = useState<string>(getLocalDateString(today));
// ===== 데이터 상태 (수주관리 패턴) =====
const [stocks, setStocks] = useState<StockItem[]>([]);

View File

@@ -46,6 +46,7 @@ import {
} from './types';
import type { ShipmentItem, ShipmentStatus, ShipmentStats } from './types';
import { parseISO } from 'date-fns';
import { getLocalDateString } from '@/lib/utils/date';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 페이지당 항목 수
@@ -61,11 +62,11 @@ export function ShipmentList() {
const today = new Date();
const [startDate, setStartDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth(), 1);
return d.toISOString().split('T')[0];
return getLocalDateString(d);
});
const [endDate, setEndDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return d.toISOString().split('T')[0];
return getLocalDateString(d);
});
// ===== 캘린더 상태 =====

View File

@@ -27,16 +27,12 @@ import {
FREIGHT_COST_STYLES,
} from './types';
import type { VehicleDispatchDetail as VehicleDispatchDetailType } from './types';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
interface VehicleDispatchDetailProps {
id: string;
}
// 금액 포맷
function formatAmount(amount: number): string {
return amount.toLocaleString('ko-KR');
}
export function VehicleDispatchDetail({ id }: VehicleDispatchDetailProps) {
const router = useRouter();

View File

@@ -34,16 +34,13 @@ import type {
FreightCostType,
} from './types';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
// 운임비용 옵션
const freightCostOptions: { value: FreightCostType; label: string }[] = Object.entries(
FREIGHT_COST_LABELS
).map(([value, label]) => ({ value: value as FreightCostType, label }));
// 금액 포맷
function formatAmount(amount: number): string {
return amount.toLocaleString('ko-KR');
}
interface VehicleDispatchEditProps {
id: string;
}

View File

@@ -39,14 +39,11 @@ import {
FREIGHT_COST_STYLES,
} from './types';
import type { VehicleDispatchItem, VehicleDispatchStats } from './types';
import { formatNumber as formatAmount } from '@/lib/utils/amount';
import { getLocalDateString } from '@/lib/utils/date';
const ITEMS_PER_PAGE = 20;
// 금액 포맷
function formatAmount(amount: number): string {
return amount.toLocaleString('ko-KR');
}
export function VehicleDispatchList() {
const router = useRouter();
@@ -57,11 +54,11 @@ export function VehicleDispatchList() {
const today = new Date();
const [startDate, setStartDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth(), 1);
return d.toISOString().split('T')[0];
return getLocalDateString(d);
});
const [endDate, setEndDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return d.toISOString().split('T')[0];
return getLocalDateString(d);
});
// 초기 통계 로드

View File

@@ -40,6 +40,7 @@ import {
DISTRIBUTION_STATUS_LABELS,
DISTRIBUTION_STATUS_STYLES,
} from './types';
import { getLocalDateString } from '@/lib/utils/date';
import {
getPriceDistributionList,
createPriceDistribution,
@@ -57,8 +58,8 @@ export function PriceDistributionList() {
// 날짜 범위 상태 (최근 30일)
const today = new Date();
const thirtyDaysAgo = new Date(today.getTime() - 30 * 24 * 60 * 60 * 1000);
const [startDate, setStartDate] = useState<string>(thirtyDaysAgo.toISOString().split('T')[0]);
const [endDate, setEndDate] = useState<string>(today.toISOString().split('T')[0]);
const [startDate, setStartDate] = useState<string>(getLocalDateString(thirtyDaysAgo));
const [endDate, setEndDate] = useState<string>(getLocalDateString(today));
// 데이터 로드
const loadData = useCallback(async () => {

View File

@@ -1,5 +1,6 @@
'use server';
import { getLocalDateString } from '@/lib/utils/date';
import type {
PriceDistributionListItem,
PriceDistributionDetail,
@@ -362,9 +363,9 @@ export async function createPriceDistribution(): Promise<{
distributionNo: String(Math.floor(100000 + Math.random() * 900000)),
distributionName: name,
status: 'initial',
createdAt: now.toISOString().split('T')[0],
createdAt: getLocalDateString(now),
documentNo: '',
effectiveDate: now.toISOString().split('T')[0],
effectiveDate: getLocalDateString(now),
officePhone: '',
orderPhone: '',
author: '현재사용자',

View File

@@ -11,6 +11,7 @@
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { ArrowLeft, Save, Trash2, X } from 'lucide-react';
import { getTodayString } from '@/lib/utils/date';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
@@ -31,6 +32,7 @@ import { toast } from 'sonner';
import { createPricingTable, updatePricingTable, deletePricingTable } from './actions';
import { calculateSellingPrice } from './types';
import type { PricingTable, PricingTableFormData, GradePricing, TradeGrade, PricingTableStatus } from './types';
import { formatNumber } from '@/lib/utils/amount';
interface PricingTableFormProps {
mode: 'create' | 'edit';
@@ -205,8 +207,6 @@ export function PricingTableForm({ mode, initialData }: PricingTableFormProps) {
router.push('/ko/master-data/pricing-table-management');
};
const formatNumber = (num: number) => num.toLocaleString('ko-KR');
return (
<PageLayout>
<PageHeader
@@ -309,7 +309,7 @@ export function PricingTableForm({ mode, initialData }: PricingTableFormProps) {
<div className="space-y-2">
<Label></Label>
<Input
value={isEdit ? initialData?.changedDate ?? '' : new Date().toISOString().split('T')[0]}
value={isEdit ? initialData?.changedDate ?? '' : getTodayString()}
disabled
/>
</div>

View File

@@ -2,6 +2,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import { DollarSign, Plus } from 'lucide-react';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -22,6 +23,7 @@ import {
deletePricingTable,
deletePricingTables,
} from './actions';
import { formatNumber } from '@/lib/utils/amount';
export default function PricingTableListClient() {
const router = useRouter();
@@ -34,8 +36,7 @@ export default function PricingTableListClient() {
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// 날짜 범위
const [startDate, setStartDate] = useState('2025-01-01');
const [endDate, setEndDate] = useState('2025-12-31');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// 검색어
const [searchQuery, setSearchQuery] = useState('');
@@ -135,9 +136,6 @@ export default function PricingTableListClient() {
[selectedGrade]
);
// 숫자 포맷
const formatNumber = (num: number) => num.toLocaleString('ko-KR');
// ===== Config =====
const config: UniversalListConfig<PricingTable> = useMemo(
() => ({

View File

@@ -1,5 +1,6 @@
'use server';
import { getTodayString } from '@/lib/utils/date';
import type { PricingTable, PricingTableFormData, TradeGrade } from './types';
// ============================================================================
@@ -238,7 +239,7 @@ export async function createPricingTable(data: PricingTableFormData): Promise<{
processingCost: data.processingCost,
status: data.status,
author: '현재사용자',
changedDate: new Date().toISOString().split('T')[0],
changedDate: getTodayString(),
gradePricings: data.gradePricings,
};
return { success: true, data: newItem };
@@ -262,7 +263,7 @@ export async function updatePricingTable(
const updated: PricingTable = {
...existing,
...data,
changedDate: new Date().toISOString().split('T')[0],
changedDate: getTodayString(),
};
return { success: true, data: updated };
}

View File

@@ -11,6 +11,7 @@
import { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { useDateRange } from '@/hooks';
import { Wrench, Plus, GripVertical } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow, TableHead } from '@/components/ui/table';
@@ -45,8 +46,7 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-01-01');
const [endDate, setEndDate] = useState('2025-12-31');
const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
// 검색어 상태
const [searchQuery, setSearchQuery] = useState('');

View File

@@ -47,6 +47,7 @@ import { getInspections, getInspectionStats, getInspectionCalendar } from './act
import { statusColorMap } from './mockData';
import { parseISO } from 'date-fns';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { getLocalDateString } from '@/lib/utils/date';
import type { ProductInspection, InspectionStats, InspectionStatus } from './types';
const ITEMS_PER_PAGE = 20;
@@ -61,11 +62,11 @@ export function InspectionList() {
const today = new Date();
const [startDate, setStartDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth(), 1);
return d.toISOString().split('T')[0];
return getLocalDateString(d);
});
const [endDate, setEndDate] = useState(() => {
const d = new Date(today.getFullYear(), today.getMonth() + 1, 0);
return d.toISOString().split('T')[0];
return getLocalDateString(d);
});
// ===== 캘린더 상태 =====

View File

@@ -12,6 +12,7 @@
*/
import { DocumentViewer } from '@/components/document-system';
import { getTodayString } from '@/lib/utils/date';
import type { QuoteFormDataV2 } from './QuoteRegistration';
interface QuoteTransactionModalProps {
@@ -55,7 +56,7 @@ export function QuoteTransactionModal({
const hasDiscount = discountAmount > 0;
// 오늘 날짜
const today = new Date().toISOString().split('T')[0];
const today = getTodayString();
return (
<DocumentViewer

View File

@@ -14,6 +14,7 @@ import { usePermission } from '@/hooks/usePermission';
import { cancelSubscription, requestDataExport } from './actions';
import type { SubscriptionInfo } from './types';
import { PLAN_LABELS, SUBSCRIPTION_STATUS_LABELS } from './types';
import { formatAmountWon as formatCurrency } from '@/lib/utils/amount';
// ===== Props 타입 =====
interface SubscriptionClientProps {
@@ -31,11 +32,6 @@ const formatDate = (dateStr: string): string => {
return `${year}${month}${day}`;
};
// ===== 금액 포맷 함수 =====
const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
};
export function SubscriptionClient({ initialData }: SubscriptionClientProps) {
const { canExport } = usePermission();
const [subscription, setSubscription] = useState<SubscriptionInfo>(initialData);

View File

@@ -12,6 +12,7 @@ import { PageHeader } from '@/components/organisms/PageHeader';
import type { SubscriptionInfo } from './types';
import { PLAN_LABELS } from './types';
import { requestDataExport, cancelSubscription } from './actions';
import { formatAmountWon as formatCurrency } from '@/lib/utils/amount';
// ===== 기본 저장공간 (10GB) =====
const DEFAULT_STORAGE_LIMIT = 10 * 1024 * 1024 * 1024; // 10GB in bytes
@@ -42,11 +43,6 @@ const formatDate = (dateStr: string): string => {
return `${year}${month}${day}`;
};
// ===== 금액 포맷 함수 =====
const formatCurrency = (amount: number): string => {
return new Intl.NumberFormat('ko-KR').format(amount) + '원';
};
interface SubscriptionManagementProps {
initialData: SubscriptionInfo | null;
}

View File

@@ -87,7 +87,7 @@ export function ForkliftDetail({ mode, initialData, id }: ForkliftDetailProps) {
const handleEdit = () => {
if (id) {
router.push(`/vehicle-management/forklift/${id}/edit`);
router.push(`/vehicle-management/forklift/${id}?mode=edit`);
}
};

View File

@@ -48,7 +48,7 @@ export function ForkliftList({ initialData }: ForkliftListProps) {
const handleEdit = useCallback(
(forklift: Forklift) => {
router.push(`/vehicle-management/forklift/${forklift.id}/edit`);
router.push(`/vehicle-management/forklift/${forklift.id}?mode=edit`);
},
[router]
);

View File

@@ -94,7 +94,7 @@ export function VehicleDetail({ mode, initialData, id }: VehicleDetailProps) {
// ===== 수정 모드 전환 =====
const handleEdit = () => {
if (id) {
router.push(`/vehicle-management/vehicle/${id}/edit`);
router.push(`/vehicle-management/vehicle/${id}?mode=edit`);
}
};

View File

@@ -48,7 +48,7 @@ export function VehicleList({ initialData }: VehicleListProps) {
const handleEdit = useCallback(
(vehicle: Vehicle) => {
router.push(`/vehicle-management/vehicle/${vehicle.id}/edit`);
router.push(`/vehicle-management/vehicle/${vehicle.id}?mode=edit`);
},
[router]
);

View File

@@ -88,7 +88,7 @@ export function VehicleLogDetail({ mode, initialData, id }: VehicleLogDetailProp
const handleEdit = () => {
if (id) {
router.push(`/vehicle-management/vehicle-log/${id}/edit`);
router.push(`/vehicle-management/vehicle-log/${id}?mode=edit`);
}
};

View File

@@ -5,6 +5,7 @@
*/
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { getTodayString } from '@/lib/utils/date';
import type { VehicleLog, VehicleLogFormData, ActionResponse, ListResponse } from '../types';
// ===== Mock 데이터 (5130 레거시 기준) =====
@@ -123,12 +124,12 @@ export async function createVehicleLog(
try {
const newLog: VehicleLog = {
id: String(Date.now()),
writeDate: formData.writeDate || new Date().toISOString().split('T')[0],
writeDate: formData.writeDate || getTodayString(),
vehicleInfo: formData.vehicleInfo || '',
writer: formData.writer || '',
title: formData.title || '',
images: formData.images || [],
createdAt: new Date().toISOString().split('T')[0],
createdAt: getTodayString(),
};
mockVehicleLogs.push(newLog);
return { success: true, data: newLog };

View File

@@ -50,7 +50,7 @@ export function VehicleLogList({ initialData }: VehicleLogListProps) {
const handleEdit = useCallback(
(log: VehicleLog) => {
router.push(`/vehicle-management/vehicle-log/${log.id}/edit`);
router.push(`/vehicle-management/vehicle-log/${log.id}?mode=edit`);
},
[router]
);

View File

@@ -37,6 +37,13 @@ export type {
UseDetailPermissionsReturn,
} from './useDetailPermissions';
// ===== 날짜 범위 / 목록 페이지 =====
export { useDateRange } from './useDateRange';
export type { DateRangePreset, UseDateRangeReturn } from './useDateRange';
export { useAccountingListPage, DEFAULT_PAGINATION } from './useAccountingListPage';
export type { PaginationMeta, UseAccountingListPageReturn } from './useAccountingListPage';
// ===== 기존 훅 =====
export { usePermission } from './usePermission';
export { useAuthGuard } from './useAuthGuard';

View File

@@ -0,0 +1,79 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
// ===== 공통 페이지네이션 기본값 =====
export const DEFAULT_PAGINATION = {
currentPage: 1,
lastPage: 1,
perPage: 100,
total: 0,
};
export type PaginationMeta = {
currentPage: number;
lastPage: number;
perPage: number;
total: number;
};
// ===== 반환 타입 =====
export interface UseAccountingListPageReturn<TData> {
data: TData[];
pagination: PaginationMeta;
isLoading: boolean;
mode: string | null;
}
/**
* 회계 목록 페이지 공통 훅
*
* searchParams의 mode를 읽어서 skipModes가 아닌 경우 fetchFn을 호출합니다.
* data, pagination, isLoading, mode를 반환합니다.
*
* @param fetchFn - 데이터를 가져오는 함수 (클로저로 params 전달)
* @param options.skipModes - 데이터 로드를 건너뛸 mode 값 (기본: ['new'])
*
* @example
* const { data, pagination, isLoading, mode } = useAccountingListPage(
* () => getSales({ perPage: 100 })
* );
*/
export function useAccountingListPage<TData>(
fetchFn: () => Promise<{ success?: boolean; data: TData[]; pagination?: PaginationMeta }>,
options?: { skipModes?: string[] }
): UseAccountingListPageReturn<TData> {
const searchParams = useSearchParams();
const mode = searchParams.get('mode');
const skipModes = options?.skipModes ?? ['new'];
const [data, setData] = useState<TData[]>([]);
const [pagination, setPagination] = useState<PaginationMeta>(DEFAULT_PAGINATION);
const [isLoading, setIsLoading] = useState(true);
// fetchFn을 ref로 보관하여 stale closure 방지
const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
useEffect(() => {
if (mode && skipModes.includes(mode)) {
setIsLoading(false);
return;
}
setIsLoading(true);
fetchFnRef.current()
.then(result => {
setData(result.data ?? []);
if (result.pagination) {
setPagination(result.pagination);
}
})
.finally(() => setIsLoading(false));
// mode 변경 시에만 재실행
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mode]);
return { data, pagination, isLoading, mode };
}

60
src/hooks/useDateRange.ts Normal file
View File

@@ -0,0 +1,60 @@
'use client';
import { useState } from 'react';
import { getLocalDateString } from '@/lib/utils/date';
// ===== 프리셋 타입 =====
export type DateRangePreset = 'currentYear' | 'currentMonth' | 'today' | 'none';
// ===== 반환 타입 =====
export interface UseDateRangeReturn {
startDate: string;
endDate: string;
setStartDate: React.Dispatch<React.SetStateAction<string>>;
setEndDate: React.Dispatch<React.SetStateAction<string>>;
}
// ===== 프리셋별 초기값 계산 =====
function getInitialDates(preset: DateRangePreset): { start: string; end: string } {
const now = new Date();
switch (preset) {
case 'currentYear': {
const year = now.getFullYear();
return { start: `${year}-01-01`, end: `${year}-12-31` };
}
case 'currentMonth': {
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const lastDay = new Date(year, now.getMonth() + 1, 0).getDate();
return {
start: `${year}-${month}-01`,
end: `${year}-${month}-${String(lastDay).padStart(2, '0')}`,
};
}
case 'today': {
const today = getLocalDateString(now);
return { start: today, end: today };
}
case 'none':
return { start: '', end: '' };
}
}
/**
* 날짜 범위 상태 관리 훅
*
* @param preset - 초기 날짜 범위 프리셋
* - 'currentYear': 올해 1/1 ~ 12/31 (회계 관리 기본값)
* - 'currentMonth': 이번 달 1일 ~ 말일
* - 'today': 오늘 ~ 오늘 (로컬 시간대 기준)
* - 'none': 빈 문자열
*
* @example
* const { startDate, endDate, setStartDate, setEndDate } = useDateRange('currentYear');
*/
export function useDateRange(preset: DateRangePreset = 'currentYear'): UseDateRangeReturn {
const [startDate, setStartDate] = useState(() => getInitialDates(preset).start);
const [endDate, setEndDate] = useState(() => getInitialDates(preset).end);
return { startDate, endDate, setStartDate, setEndDate };
}