feat(WEB): 공사관리 리스트 공통화 및 캘린더/포맷터 기능 개선

공사관리 리스트 공통화:
- 입찰/계약/견적/인수인계/이슈/품목/노무/현장/파트너/단가/기성/현장브리핑/구조검토/유틸리티/작업자현황 리스트 공통 포맷터 적용
- 중복 포맷팅 로직 제거 (-530줄)

캘린더 기능 개선:
- CEODashboard CalendarSection 기능 확장
- ScheduleCalendar DayCell/MonthView/WeekView 개선
- ui/calendar 컴포넌트 기능 추가

유틸리티 개선:
- date.ts 날짜 유틸 함수 추가
- formatAmount.ts 금액 포맷 함수 추가

신규 추가:
- useListHandlers 훅 추가
- src/constants/ 디렉토리 추가
- 포맷터 공통화 계획 문서 추가
- SAM ERP/MES 정체성 분석 문서 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-05 17:38:38 +09:00
parent 2639724f9f
commit 32d6e3bbbd
36 changed files with 852 additions and 530 deletions

View File

@@ -15,6 +15,7 @@ import {
import { Plus, ExternalLink } from 'lucide-react';
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar';
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
import { CALENDAR_EVENTS, type CalendarEvent } from '@/constants/calendarEvents';
import type {
CalendarScheduleItem,
CalendarViewType,
@@ -38,6 +39,8 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
construction: 'purple',
issue: 'red',
other: 'gray',
holiday: 'red',
tax: 'orange',
};
// 이슈 뱃지별 색상
@@ -118,7 +121,22 @@ export function CalendarSection({
return issuesWithDate;
}, [issuesWithDate, taskFilter]);
// ScheduleCalendar용 이벤트 변환 (스케줄 + 이슈 통합)
// 현재 연도의 공휴일/세금일정
const staticEvents: ScheduleEvent[] = useMemo(() => {
const year = currentDate.getFullYear();
const events = CALENDAR_EVENTS[year] || [];
return events.map((event: CalendarEvent) => ({
id: `${event.type}-${event.date}`,
title: event.type === 'holiday' ? `🔴 ${event.name}` : `🟠 ${event.name}`,
startDate: event.date,
endDate: event.date,
color: SCHEDULE_TYPE_COLORS[event.type] || 'gray',
data: { ...event, _type: event.type as 'holiday' | 'tax' },
}));
}, [currentDate]);
// ScheduleCalendar용 이벤트 변환 (스케줄 + 이슈 + 공휴일/세금 통합)
const calendarEvents: ScheduleEvent[] = useMemo(() => {
const scheduleEvents = filteredSchedules.map((schedule) => ({
id: schedule.id,
@@ -139,12 +157,12 @@ export function CalendarSection({
data: { ...issue, _type: 'issue' as const },
}));
return [...scheduleEvents, ...issueEvents];
}, [filteredSchedules, filteredIssues]);
return [...staticEvents, ...scheduleEvents, ...issueEvents];
}, [staticEvents, filteredSchedules, filteredIssues]);
// 선택된 날짜의 일정 + 이슈 목록
// 선택된 날짜의 일정 + 이슈 + 공휴일/세금 목록
const selectedDateItems = useMemo(() => {
if (!selectedDate) return { schedules: [], issues: [] };
if (!selectedDate) return { schedules: [], issues: [], staticEvents: [] };
// 로컬 타임존 기준으로 날짜 문자열 생성 (UTC 변환 방지)
const year = selectedDate.getFullYear();
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
@@ -157,11 +175,14 @@ export function CalendarSection({
const dateIssues = filteredIssues.filter((issue) => issue.date === dateStr);
return { schedules: dateSchedules, issues: dateIssues };
}, [selectedDate, filteredSchedules, filteredIssues]);
// 공휴일/세금일정
const dateStaticEvents = staticEvents.filter((event) => event.startDate === dateStr);
return { schedules: dateSchedules, issues: dateIssues, staticEvents: dateStaticEvents };
}, [selectedDate, filteredSchedules, filteredIssues, staticEvents]);
// 총 건수 계산
const totalItemCount = selectedDateItems.schedules.length + selectedDateItems.issues.length;
const totalItemCount = selectedDateItems.schedules.length + selectedDateItems.issues.length + selectedDateItems.staticEvents.length;
// 날짜 포맷 (기획서: "1월 6일 화요일")
const formatSelectedDate = (date: Date) => {
@@ -313,6 +334,30 @@ export function CalendarSection({
</div>
) : (
<div className="space-y-3 max-h-[calc(100vh-400px)] overflow-y-auto pr-1">
{/* 공휴일/세금일정 목록 */}
{selectedDateItems.staticEvents.map((event) => {
const eventData = event.data as CalendarEvent & { _type: string };
const isHoliday = eventData.type === 'holiday';
return (
<div
key={event.id}
className={`p-3 rounded-lg ${
isHoliday
? 'bg-red-50 border border-red-200'
: 'bg-orange-50 border border-orange-200'
}`}
>
<div className="flex items-center gap-2">
<span className="text-lg">{isHoliday ? '🔴' : '🟠'}</span>
<span className="font-medium">{eventData.name}</span>
</div>
<div className="text-xs text-muted-foreground mt-1">
{isHoliday ? '공휴일' : '세금 신고 마감일'}
</div>
</div>
);
})}
{/* 일정 목록 */}
{selectedDateItems.schedules.map((schedule) => (
<div

View File

@@ -34,11 +34,7 @@ import {
biddingDetailToFormData,
} from './types';
import { updateBidding } from './actions';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
import { formatNumber } from '@/utils/formatAmount';
interface BiddingDetailFormProps {
mode: 'view' | 'edit';
@@ -271,7 +267,7 @@ export default function BiddingDetailForm({
<div className="space-y-2">
<Label></Label>
<Input
value={formatAmount(formData.biddingAmount)}
value={formatNumber(formData.biddingAmount)}
disabled
className="bg-muted text-right font-medium"
/>
@@ -392,14 +388,14 @@ export default function BiddingDetailForm({
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell className="text-right">
{formatAmount(item.amount)}
{formatNumber(item.amount)}
</TableCell>
</TableRow>
))}
<TableRow className="bg-muted/50 font-medium">
<TableCell></TableCell>
<TableCell className="text-right">
{formatAmount(expenseTotal)}
{formatNumber(expenseTotal)}
</TableCell>
</TableRow>
</>
@@ -465,26 +461,26 @@ export default function BiddingDetailForm({
<TableCell className="text-right bg-gray-50">{item.height?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{item.weight?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{item.area?.toFixed(2) || '0.00'}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.steelScreen || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.caulking || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.rail || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.bottom || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.boxReinforce || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.shaft || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.painting || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.motor || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.controller || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.widthConstruction || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.heightConstruction || 0)}</TableCell>
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(item.unitPrice || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.steelScreen || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.caulking || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.rail || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.bottom || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.boxReinforce || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.shaft || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.painting || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.motor || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.controller || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.widthConstruction || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.heightConstruction || 0)}</TableCell>
<TableCell className="text-right bg-gray-50 font-medium">{formatNumber(item.unitPrice || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{item.expenseRate || 0}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.expense || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.expense || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{item.quantity || 0}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.cost || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.costExecution || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.marginCost || 0)}</TableCell>
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(item.marginCostExecution || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatAmount(item.expenseExecution || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.cost || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.costExecution || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.marginCost || 0)}</TableCell>
<TableCell className="text-right bg-gray-50 font-medium">{formatNumber(item.marginCostExecution || 0)}</TableCell>
<TableCell className="text-right bg-gray-50">{formatNumber(item.expenseExecution || 0)}</TableCell>
</TableRow>
))}
{/* 합계 행 */}
@@ -492,26 +488,26 @@ export default function BiddingDetailForm({
<TableCell colSpan={4} className="text-center font-bold"></TableCell>
<TableCell className="text-right">{estimateDetailTotals.weight.toFixed(2)}</TableCell>
<TableCell className="text-right">{estimateDetailTotals.area.toFixed(2)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.steelScreen)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.caulking)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.rail)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.bottom)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.boxReinforce)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.shaft)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.painting)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.motor)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.controller)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.widthConstruction)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.heightConstruction)}</TableCell>
<TableCell className="text-right font-bold">{formatAmount(estimateDetailTotals.unitPrice)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.steelScreen)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.caulking)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.rail)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.bottom)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.boxReinforce)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.shaft)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.painting)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.motor)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.controller)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.widthConstruction)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.heightConstruction)}</TableCell>
<TableCell className="text-right font-bold">{formatNumber(estimateDetailTotals.unitPrice)}</TableCell>
<TableCell className="text-right">-</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.expense)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.expense)}</TableCell>
<TableCell className="text-right">{estimateDetailTotals.quantity}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.cost)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.costExecution)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.marginCost)}</TableCell>
<TableCell className="text-right font-bold">{formatAmount(estimateDetailTotals.marginCostExecution)}</TableCell>
<TableCell className="text-right">{formatAmount(estimateDetailTotals.expenseExecution)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.cost)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.costExecution)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.marginCost)}</TableCell>
<TableCell className="text-right font-bold">{formatNumber(estimateDetailTotals.marginCostExecution)}</TableCell>
<TableCell className="text-right">{formatNumber(estimateDetailTotals.expenseExecution)}</TableCell>
</TableRow>
</>
)}

View File

@@ -11,9 +11,9 @@
* - 등록 버튼 없음 (견적완료 시 자동 등록)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useMemo, useEffect } from 'react';
import { FileText, Clock, Trophy, Pencil } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -33,6 +33,8 @@ import {
BIDDING_STATUS_LABELS,
} from './types';
import { getBiddingList, getBiddingStats, deleteBidding, deleteBiddings } from './actions';
import { formatNumber } from '@/utils/formatAmount';
import { formatDate } from '@/utils/date';
// 테이블 컬럼 정의
const tableColumns = [
@@ -64,32 +66,14 @@ const MOCK_BIDDERS = [
{ value: 'lee', label: '이영희' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date
.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.replace(/\. /g, '-')
.replace('.', '');
}
interface BiddingListClientProps {
initialData?: Bidding[];
initialStats?: BiddingStats;
}
export default function BiddingListClient({ initialData = [], initialStats }: BiddingListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit } = useListHandlers<Bidding>('construction/project/bidding');
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
// Stats 카드 클릭 필터용
@@ -113,21 +97,6 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
}
}, [initialStats]);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: Bidding) => {
router.push(`/ko/construction/project/bidding/${item.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(item: Bidding) => {
router.push(`/ko/construction/project/bidding/${item.id}?mode=edit`);
},
[router]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Bidding> = useMemo(
() => ({
@@ -363,7 +332,7 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
<TableCell>{item.projectName}</TableCell>
<TableCell>{item.bidderName}</TableCell>
<TableCell className="text-center">{item.totalCount}</TableCell>
<TableCell className="text-right">{formatAmount(item.biddingAmount)}</TableCell>
<TableCell className="text-right">{formatNumber(item.biddingAmount)}</TableCell>
<TableCell className="text-center">{formatDate(item.bidDate)}</TableCell>
<TableCell className="text-center">{formatDate(item.submissionDate)}</TableCell>
<TableCell className="text-center">{formatDate(item.confirmDate)}</TableCell>
@@ -411,7 +380,7 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
onClick={() => handleRowClick(item)}
details={[
{ label: '거래처', value: item.partnerName },
{ label: '입찰금액', value: `${formatAmount(item.biddingAmount)}` },
{ label: '입찰금액', value: `${formatNumber(item.biddingAmount)}` },
{ label: '입찰일자', value: formatDate(item.biddingDate) },
{ label: '총 개소', value: `${item.totalCount}` },
]}

View File

@@ -40,11 +40,7 @@ import {
getEmptyElectronicApproval,
} from '../common';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
import { formatNumber } from '@/utils/formatAmount';
interface ContractDetailFormProps {
mode: 'view' | 'edit' | 'create';
@@ -365,7 +361,7 @@ export default function ContractDetailForm({
<Label></Label>
<Input
type="text"
value={formatAmount(formData.contractAmount)}
value={formatNumber(formData.contractAmount)}
onChange={(e) => {
const value = e.target.value.replace(/[^0-9]/g, '');
handleFieldChange('contractAmount', parseInt(value) || 0);

View File

@@ -10,9 +10,9 @@
* - filterConfig (multi: 거래처, 계약담당자, 공사PM / single: 상태, 정렬)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useMemo, useEffect } from 'react';
import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -32,6 +32,8 @@ import {
CONTRACT_STATUS_LABELS,
} from './types';
import { getContractList, getContractStats, deleteContract, deleteContracts } from './actions';
import { formatNumber } from '@/utils/formatAmount';
import { formatDate, formatDateRange } from '@/utils/date';
// 테이블 컬럼 정의
const tableColumns = [
@@ -67,40 +69,14 @@ const MOCK_CONSTRUCTION_PMS = [
{ value: 'park', label: '박PM' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date
.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.replace(/\. /g, '-')
.replace('.', '');
}
// 계약기간 포맷팅
function formatPeriod(startDate: string | null, endDate: string | null): string {
const start = formatDate(startDate);
const end = formatDate(endDate);
if (start === '-' && end === '-') return '-';
return `${start} ~ ${end}`;
}
interface ContractListClientProps {
initialData?: Contract[];
initialStats?: ContractStats;
}
export default function ContractListClient({ initialData = [], initialStats }: ContractListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit } = useListHandlers<Contract>('construction/project/contract');
// ===== 외부 상태 =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
@@ -120,21 +96,6 @@ export default function ContractListClient({ initialData = [], initialStats }: C
}
}, [initialStats]);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: Contract) => {
router.push(`/ko/construction/project/contract/${item.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(item: Contract) => {
router.push(`/ko/construction/project/contract/${item.id}?mode=edit`);
},
[router]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Contract> = useMemo(
() => ({
@@ -376,9 +337,9 @@ export default function ContractListClient({ initialData = [], initialStats }: C
<TableCell className="text-center">{item.contractManagerName}</TableCell>
<TableCell className="text-center">{item.constructionPMName || '-'}</TableCell>
<TableCell className="text-center">{item.totalLocations}</TableCell>
<TableCell className="text-right">{formatAmount(item.contractAmount)}</TableCell>
<TableCell className="text-right">{formatNumber(item.contractAmount)}</TableCell>
<TableCell className="text-center">
{formatPeriod(item.contractStartDate, item.contractEndDate)}
{formatDateRange(item.contractStartDate, item.contractEndDate)}
</TableCell>
<TableCell className="text-center">
<span className={CONTRACT_STATUS_STYLES[item.status]}>{CONTRACT_STATUS_LABELS[item.status]}</span>
@@ -433,7 +394,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
details={[
{ label: '거래처', value: item.partnerName },
{ label: '총 개소', value: `${item.totalLocations}` },
{ label: '계약금액', value: `${formatAmount(item.contractAmount)}` },
{ label: '계약금액', value: `${formatNumber(item.contractAmount)}` },
{ label: '계약담당자', value: item.contractManagerName },
{ label: '공사PM', value: item.constructionPMName || '-' },
]}

View File

@@ -10,9 +10,9 @@
* - filterConfig (multi: 거래처, 견적자 / single: 상태, 정렬)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useMemo, useEffect } from 'react';
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -33,6 +33,7 @@ import {
} from './types';
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates, getClientOptions, getUserOptions } from './actions';
import type { ClientOption, UserOption } from './actions';
import { formatNumber } from '@/utils/formatAmount';
// 테이블 컬럼 정의
const tableColumns = [
@@ -49,18 +50,14 @@ const tableColumns = [
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
interface EstimateListClientProps {
initialData?: Estimate[];
initialStats?: EstimateStats;
}
export default function EstimateListClient({ initialData = [], initialStats }: EstimateListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit } = useListHandlers<Estimate>('construction/project/bidding/estimates');
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
// Stats 카드 클릭 필터용
@@ -103,21 +100,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
});
}, []);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: Estimate) => {
router.push(`/ko/construction/project/bidding/estimates/${item.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(item: Estimate) => {
router.push(`/ko/construction/project/bidding/estimates/${item.id}?mode=edit`);
},
[router]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Estimate> = useMemo(
() => ({
@@ -346,7 +328,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
<TableCell>{item.projectName}</TableCell>
<TableCell className="text-center">{item.estimatorName}</TableCell>
<TableCell className="text-center">{item.itemCount}</TableCell>
<TableCell className="text-right">{formatAmount(item.estimateAmount)}</TableCell>
<TableCell className="text-right">{formatNumber(item.estimateAmount)}</TableCell>
<TableCell className="text-center">{item.completedDate || '-'}</TableCell>
<TableCell className="text-center">{item.bidDate || '-'}</TableCell>
<TableCell className="text-center">
@@ -391,7 +373,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
details={[
{ label: '거래처', value: item.partnerName },
{ label: '견적자', value: item.estimatorName },
{ label: '견적금액', value: `${formatAmount(item.estimateAmount)}` },
{ label: '견적금액', value: `${formatNumber(item.estimateAmount)}` },
{ label: '입찰일', value: item.bidDate || '-' },
]}
/>

View File

@@ -12,11 +12,7 @@
import type { EstimateDetailFormData } from '../types';
import type { CompanyInfo } from '../actions';
import { DocumentHeader } from '@/components/document-system';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
import { formatNumber } from '@/utils/formatAmount';
// 금액을 한글로 변환
function amountToKorean(amount: number): string {
@@ -169,9 +165,9 @@ export function EstimateDocumentContent({ data }: EstimateDocumentContentProps)
<td className="border border-gray-400 px-3 py-2">{item.name}</td>
<td className="border border-gray-400 px-3 py-2 text-center">{item.quantity}</td>
<td className="border border-gray-400 px-3 py-2 text-center">{item.unit}</td>
<td className="border border-gray-400 px-3 py-2 text-right">{formatAmount(item.materialCost)}</td>
<td className="border border-gray-400 px-3 py-2 text-right">{formatAmount(item.laborCost)}</td>
<td className="border border-gray-400 px-3 py-2 text-right font-medium">{formatAmount(item.totalCost)}</td>
<td className="border border-gray-400 px-3 py-2 text-right">{formatNumber(item.materialCost)}</td>
<td className="border border-gray-400 px-3 py-2 text-right">{formatNumber(item.laborCost)}</td>
<td className="border border-gray-400 px-3 py-2 text-right font-medium">{formatNumber(item.totalCost)}</td>
<td className="border border-gray-400 px-3 py-2">{item.remarks}</td>
</tr>
))
@@ -180,13 +176,13 @@ export function EstimateDocumentContent({ data }: EstimateDocumentContentProps)
<tr className="font-medium">
<td className="border border-gray-400 px-3 py-2 text-center" colSpan={3}> </td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(formData.summaryItems.reduce((sum, item) => sum + item.materialCost, 0))}
{formatNumber(formData.summaryItems.reduce((sum, item) => sum + item.materialCost, 0))}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(formData.summaryItems.reduce((sum, item) => sum + item.laborCost, 0))}
{formatNumber(formData.summaryItems.reduce((sum, item) => sum + item.laborCost, 0))}
</td>
<td className="border border-gray-400 px-3 py-2 text-right">
{formatAmount(formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0))}
{formatNumber(formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0))}
</td>
<td className="border border-gray-400 px-3 py-2"></td>
</tr>
@@ -242,16 +238,16 @@ export function EstimateDocumentContent({ data }: EstimateDocumentContentProps)
<td className="border border-gray-400 px-2 py-1 text-center">{index + 1}</td>
<td className="border border-gray-400 px-2 py-1">{item.name}</td>
<td className="border border-gray-400 px-2 py-1">{item.material}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatAmount(item.width)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatAmount(item.height)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatNumber(item.width)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatNumber(item.height)}</td>
<td className="border border-gray-400 px-2 py-1 text-center">{item.quantity}</td>
<td className="border border-gray-400 px-2 py-1 text-center">SET</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatAmount(item.unitPrice)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatAmount(item.materialCost)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatAmount(item.laborCost)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatAmount(item.laborCost * item.quantity)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatAmount(item.totalPrice)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatAmount(item.totalCost)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatNumber(item.unitPrice)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatNumber(item.materialCost)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatNumber(item.laborCost)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatNumber(item.laborCost * item.quantity)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatNumber(item.totalPrice)}</td>
<td className="border border-gray-400 px-2 py-1 text-right">{formatNumber(item.totalCost)}</td>
</tr>
))
)}
@@ -264,15 +260,15 @@ export function EstimateDocumentContent({ data }: EstimateDocumentContentProps)
<td className="border border-gray-400 px-2 py-1 text-center">SET</td>
<td className="border border-gray-400 px-2 py-1"></td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(formData.detailItems.reduce((sum, item) => sum + item.materialCost, 0))}
{formatNumber(formData.detailItems.reduce((sum, item) => sum + item.materialCost, 0))}
</td>
<td className="border border-gray-400 px-2 py-1"></td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(formData.detailItems.reduce((sum, item) => sum + item.laborCost * item.quantity, 0))}
{formatNumber(formData.detailItems.reduce((sum, item) => sum + item.laborCost * item.quantity, 0))}
</td>
<td className="border border-gray-400 px-2 py-1"></td>
<td className="border border-gray-400 px-2 py-1 text-right">
{formatAmount(formData.detailItems.reduce((sum, item) => sum + item.totalCost, 0))}
{formatNumber(formData.detailItems.reduce((sum, item) => sum + item.totalCost, 0))}
</td>
</tr>
{/* 비고 행 */}

View File

@@ -1,4 +1,2 @@
// 금액 포맷팅
export function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 공통 유틸 re-export (backward compatibility)
export { formatNumber as formatAmount } from '@/utils/formatAmount';

View File

@@ -49,11 +49,7 @@ import {
type ElectronicApproval,
getEmptyElectronicApproval,
} from '../common';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
import { formatNumber } from '@/utils/formatAmount';
interface HandoverReportDetailFormProps {
mode: 'view' | 'edit';
@@ -315,7 +311,7 @@ export default function HandoverReportDetailForm({
<Label> ()</Label>
<Input
type="text"
value={formatAmount(formData.contractAmount)}
value={formatNumber(formData.contractAmount)}
onChange={(e) => {
const value = e.target.value.replace(/[^0-9]/g, '');
handleFieldChange('contractAmount', parseInt(value) || 0);
@@ -493,7 +489,7 @@ export default function HandoverReportDetailForm({
<TableCell className="text-center">{item.no}</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell>{item.product}</TableCell>
<TableCell className="text-right">{formatAmount(item.quantity)}</TableCell>
<TableCell className="text-right">{formatNumber(item.quantity)}</TableCell>
<TableCell>
{isEditMode ? (
<Input

View File

@@ -11,9 +11,9 @@
* - 등록 버튼 없음 (계약 종료 시 자동 등록)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useMemo, useEffect } from 'react';
import { FileText, Clock, CheckCircle, Pencil } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -33,6 +33,8 @@ import {
HANDOVER_STATUS_STYLES,
} from './types';
import { getHandoverReportList, getHandoverReportStats } from './actions';
import { formatNumber } from '@/utils/formatAmount';
import { formatDateRange } from '@/utils/date';
// 테이블 컬럼 정의
const tableColumns = [
@@ -71,33 +73,6 @@ const MOCK_CONSTRUCTION_PMS = [
{ value: 'park', label: '박PM' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date
.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
.replace(/\. /g, '-')
.replace('.', '');
}
// 계약기간 포맷팅
function formatPeriod(startDate: string | null, endDate: string | null): string {
const start = formatDate(startDate);
const end = formatDate(endDate);
if (start === '-' && end === '-') return '-';
return `${start} ~ ${end}`;
}
interface HandoverReportListClientProps {
initialData?: HandoverReport[];
initialStats?: HandoverReportStats;
@@ -107,7 +82,10 @@ export default function HandoverReportListClient({
initialData = [],
initialStats,
}: HandoverReportListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit } = useListHandlers<HandoverReport>(
'construction/project/contract/handover-report'
);
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
@@ -127,21 +105,6 @@ export default function HandoverReportListClient({
}
}, [initialStats]);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(report: HandoverReport) => {
router.push(`/ko/construction/project/contract/handover-report/${report.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(report: HandoverReport) => {
router.push(`/ko/construction/project/contract/handover-report/${report.id}?mode=edit`);
},
[router]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<HandoverReport> = useMemo(
() => ({
@@ -363,9 +326,9 @@ export default function HandoverReportListClient({
<TableCell className="text-center">{item.contractManagerName}</TableCell>
<TableCell className="text-center">{item.constructionPMName || '-'}</TableCell>
<TableCell className="text-center">{item.totalSites}</TableCell>
<TableCell className="text-right">{formatAmount(item.contractAmount)}</TableCell>
<TableCell className="text-right">{formatNumber(item.contractAmount)}</TableCell>
<TableCell className="text-center">
{formatPeriod(item.contractStartDate, item.contractEndDate)}
{formatDateRange(item.contractStartDate, item.contractEndDate)}
</TableCell>
<TableCell className="text-center">
<span className={HANDOVER_STATUS_STYLES[item.status]}>
@@ -410,7 +373,7 @@ export default function HandoverReportListClient({
onClick={() => handleRowClick(item)}
details={[
{ label: '거래처', value: item.partnerName },
{ label: '계약금액', value: `${formatAmount(item.contractAmount)}` },
{ label: '계약금액', value: `${formatNumber(item.contractAmount)}` },
{ label: '계약담당자', value: item.contractManagerName },
{ label: '총 개소', value: `${item.totalSites}개소` },
]}

View File

@@ -8,12 +8,7 @@ import {
import { toast } from 'sonner';
import { useRouter } from 'next/navigation';
import type { HandoverReportDetail } from '../types';
// 금액 포맷팅
function formatAmount(amount: number | undefined | null): string {
if (amount === undefined || amount === null) return '0';
return new Intl.NumberFormat('ko-KR').format(amount);
}
import { formatNumber } from '@/utils/formatAmount';
// 날짜 포맷팅 (년월)
function formatYearMonth(dateStr: string | null): string {
@@ -110,7 +105,7 @@ export function HandoverReportDocumentModal({
{/* 계약금액 (공급가액) */}
<tr>
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium"> ()</th>
<td className="border border-gray-300 px-4 py-3"> {formatAmount(report.contractAmount)}</td>
<td className="border border-gray-300 px-4 py-3"> {formatNumber(report.contractAmount)}</td>
</tr>
{/* 계약 ITEM - 기획서: 구분, 수량, 비고 3컬럼 */}
@@ -137,7 +132,7 @@ export function HandoverReportDocumentModal({
{item.name || '-'}
</td>
<td className={`px-4 py-2 text-center border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
{formatAmount(item.quantity)}
{formatNumber(item.quantity)}
</td>
<td className={`px-4 py-2 border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
{item.remark || '-'}
@@ -160,7 +155,7 @@ export function HandoverReportDocumentModal({
<th className="px-4 py-2 bg-gray-50 text-left font-medium w-40">2 </th>
<td className="px-4 py-2">
{report.hasSecondaryPiping
? `포함 (${formatAmount(report.secondaryPipingAmount)})`
? `포함 (${formatNumber(report.secondaryPipingAmount)})`
: '미포함'}
</td>
</tr>
@@ -168,7 +163,7 @@ export function HandoverReportDocumentModal({
<th className="border-t border-gray-300 px-4 py-2 bg-gray-50 text-left font-medium"> & </th>
<td className="border-t border-gray-300 px-4 py-2">
{report.hasCoating
? `포함 (${formatAmount(report.coatingAmount)})`
? `포함 (${formatNumber(report.coatingAmount)})`
: '미포함'}
</td>
</tr>
@@ -182,9 +177,9 @@ export function HandoverReportDocumentModal({
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle"> </th>
<td className="border border-gray-300 px-4 py-3">
<div className="space-y-1">
<div> : {formatAmount(report.externalEquipmentCost?.shippingCost)}</div>
<div> : {formatAmount(report.externalEquipmentCost?.highAltitudeWork)}</div>
<div> : {formatAmount(report.externalEquipmentCost?.publicExpense)}</div>
<div> : {formatNumber(report.externalEquipmentCost?.shippingCost)}</div>
<div> : {formatNumber(report.externalEquipmentCost?.highAltitudeWork)}</div>
<div> : {formatNumber(report.externalEquipmentCost?.publicExpense)}</div>
</div>
</td>
</tr>

View File

@@ -12,8 +12,8 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Pencil, Inbox, Clock, CheckCircle, XCircle, Undo2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -48,6 +48,7 @@ import {
getIssueStats,
withdrawIssues,
} from './actions';
import { formatDate } from '@/utils/date';
// 테이블 컬럼 정의
const tableColumns = [
@@ -67,12 +68,6 @@ const tableColumns = [
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 날짜 포맷
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
return dateStr.split('T')[0];
}
interface IssueManagementListClientProps {
initialData?: Issue[];
initialStats?: IssueStats;
@@ -82,7 +77,10 @@ export default function IssueManagementListClient({
initialData = [],
initialStats,
}: IssueManagementListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit, router } = useListHandlers<Issue>(
'construction/project/issue-management'
);
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'received' | 'in_progress' | 'resolved' | 'unresolved'>('all');
@@ -105,21 +103,7 @@ export default function IssueManagementListClient({
}
}, [initialStats]);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: Issue) => {
router.push(`/ko/construction/project/issue-management/${item.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(item: Issue) => {
router.push(`/ko/construction/project/issue-management/${item.id}?mode=edit`);
},
[router]
);
// ===== 추가 핸들러 =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/project/issue-management?mode=new');
}, [router]);

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format, startOfYear, endOfYear } from 'date-fns';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Package, Plus, Pencil, Trash2, PackageCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
@@ -46,7 +46,8 @@ export default function ItemManagementClient({
initialData = [],
initialStats,
}: ItemManagementClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, router } = useListHandlers<Item>('construction/order/base-info/items');
const today = new Date();
// 날짜 상태 (당해년도 기본값)
@@ -208,13 +209,7 @@ export default function ItemManagementClient({
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(item: Item) => {
router.push(`/ko/construction/order/base-info/items/${item.id}?mode=view`);
},
[router]
);
// ===== 추가 핸들러 (handleRowClick은 Hook에서 제공) =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/order/base-info/items?mode=new');
}, [router]);

View File

@@ -12,8 +12,8 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format, startOfYear, endOfYear } from 'date-fns';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Hammer, Pencil, Trash2, HardHat } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
@@ -52,7 +52,10 @@ export default function LaborManagementClient({
initialData = [],
initialStats,
}: LaborManagementClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit, router } = useListHandlers<Labor>(
'construction/order/base-info/labor'
);
const today = new Date();
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
@@ -96,21 +99,7 @@ export default function LaborManagementClient({
return value.toFixed(2);
}, []);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(labor: Labor) => {
router.push(`/ko/construction/order/base-info/labor/${labor.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(labor: Labor) => {
router.push(`/ko/construction/order/base-info/labor/${labor.id}?mode=edit`);
},
[router]
);
// ===== 추가 핸들러 =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/order/base-info/labor?mode=new');
}, [router]);

View File

@@ -2,7 +2,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { getTodayString } from '@/utils/date';
import { getTodayString, formatDate } from '@/utils/date';
import { Plus, Trash2, FileText, Upload, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -47,13 +47,6 @@ interface ConstructionDetailClientProps {
mode: 'view' | 'edit';
}
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}
export default function ConstructionDetailClient({ id, mode }: ConstructionDetailClientProps) {
const router = useRouter();

View File

@@ -15,8 +15,8 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { HardHat, Pencil, Clock, CheckCircle } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -75,7 +75,10 @@ export default function ConstructionManagementListClient({
initialData = [],
initialStats,
}: ConstructionManagementListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit, router } = useListHandlers<ConstructionManagement>(
'construction/project/construction-management'
);
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all');
@@ -159,21 +162,6 @@ export default function ConstructionManagementListClient({
return dateStr.split('T')[0];
}, []);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: ConstructionManagement) => {
router.push(`/ko/construction/project/construction-management/${item.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(item: ConstructionManagement) => {
router.push(`/ko/construction/project/construction-management/${item.id}?mode=edit`);
},
[router]
);
// 달력 이벤트 핸들러
const handleCalendarDateClick = useCallback((date: Date) => {
if (selectedCalendarDate && isSameDay(selectedCalendarDate, date)) {

View File

@@ -1,8 +1,8 @@
'use client';
import { useState, useMemo, useCallback, useEffect, Fragment } from 'react';
import { useRouter } from 'next/navigation';
import { FolderKanban, ClipboardList, PlayCircle, CheckCircle2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -108,7 +108,8 @@ interface ProjectListClientProps {
}
export default function ProjectListClient({ initialData = [], initialStats }: ProjectListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, router } = useListHandlers<Project>('construction/project/execution-management');
// 상태
const [projects, setProjects] = useState<Project[]>(initialData);
@@ -238,19 +239,6 @@ export default function ProjectListClient({ initialData = [], initialStats }: Pr
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(project: Project) => {
router.push(`/ko/construction/project/execution-management/${project.id}?mode=view`);
},
[router]
);
const handleGanttProjectClick = useCallback(
(project: Project) => {
router.push(`/ko/construction/project/execution-management/${project.id}?mode=view`);
},
[router]
);
// 금액 포맷
const formatAmount = (amount: number) => {
@@ -400,7 +388,7 @@ export default function ProjectListClient({ initialData = [], initialStats }: Pr
projects={chartProjects}
viewMode={chartViewMode}
currentDate={chartDate}
onProjectClick={handleGanttProjectClick}
onProjectClick={handleRowClick}
onDateChange={setChartDate}
/>
</div>

View File

@@ -14,8 +14,8 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Package, Pencil, Trash2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -84,7 +84,10 @@ export default function OrderManagementListClient({
initialData = [],
initialStats,
}: OrderManagementListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit, router } = useListHandlers<Order>(
'construction/order/order-management'
);
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [startDate, setStartDate] = useState<string>('');
@@ -157,21 +160,7 @@ export default function OrderManagementListClient({
return dateStr.split('T')[0];
}, []);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(order: Order) => {
router.push(`/ko/construction/order/order-management/${order.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(order: Order) => {
router.push(`/ko/construction/order/order-management/${order.id}?mode=edit`);
},
[router]
);
// ===== 추가 핸들러 =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/order/order-management?mode=new');
}, [router]);

View File

@@ -12,8 +12,8 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Building2, AlertTriangle, Pencil, Trash2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -49,7 +49,10 @@ interface PartnerListClientProps {
}
export default function PartnerListClient({ initialData = [], initialStats }: PartnerListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit, router } = useListHandlers<Partner>(
'construction/project/bidding/partners'
);
// ===== 외부 상태 =====
const [stats, setStats] = useState<PartnerStats>(
@@ -67,25 +70,11 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
}
}, [initialStats]);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: Partner) => {
router.push(`/ko/construction/project/bidding/partners/${item.id}?mode=view`);
},
[router]
);
// ===== 추가 핸들러 =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/project/bidding/partners?mode=new');
}, [router]);
const handleEdit = useCallback(
(item: Partner) => {
router.push(`/ko/construction/project/bidding/partners/${item.id}?mode=edit`);
},
[router]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Partner> = useMemo(
() => ({

View File

@@ -13,8 +13,8 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { DollarSign, Package, CheckCircle, AlertCircle, Pencil, Trash2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow, TableHead } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -54,7 +54,10 @@ export default function PricingListClient({
initialData = [],
initialStats,
}: PricingListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit, router } = useListHandlers<Pricing>(
'construction/order/base-info/pricing'
);
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_use' | 'not_registered'>('all');
@@ -96,21 +99,7 @@ export default function PricingListClient({
return num.toLocaleString('ko-KR');
}, []);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(pricing: Pricing) => {
router.push(`/ko/construction/order/base-info/pricing/${pricing.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(pricing: Pricing) => {
router.push(`/ko/construction/order/base-info/pricing/${pricing.id}?mode=edit`);
},
[router]
);
// ===== 추가 핸들러 =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/order/base-info/pricing?mode=new');
}, [router]);

View File

@@ -11,9 +11,9 @@
* - 삭제 기능 없음 (조회/수정 전용)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useMemo, useEffect } from 'react';
import { FileText, Pencil } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -68,7 +68,10 @@ export default function ProgressBillingManagementListClient({
initialData = [],
initialStats,
}: ProgressBillingManagementListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit } = useListHandlers<ProgressBilling>(
'construction/billing/progress-billing-management'
);
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'contractWaiting' | 'contractComplete'>('all');
@@ -88,21 +91,6 @@ export default function ProgressBillingManagementListClient({
}
}, [initialStats]);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: ProgressBilling) => {
router.push(`/ko/construction/billing/progress-billing-management/${item.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(item: ProgressBilling) => {
router.push(`/ko/construction/billing/progress-billing-management/${item.id}?mode=edit`);
},
[router]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<ProgressBilling> = useMemo(
() => ({

View File

@@ -10,8 +10,8 @@
*/
import { useState, useMemo, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { Calendar, CalendarDays, CalendarCheck, CalendarClock, Pencil, Trash2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -90,7 +90,10 @@ interface SiteBriefingListClientProps {
}
export default function SiteBriefingListClient({ initialData = [] }: SiteBriefingListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit, router } = useListHandlers<SiteBriefing>(
'construction/project/bidding/site-briefings'
);
// Stats 탭 상태
const [activeStatTab, setActiveStatTab] = useState<'all' | 'scheduled' | 'attended'>('all');
@@ -99,21 +102,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
const [endDate, setEndDate] = useState('');
const [searchQuery, setSearchQuery] = useState('');
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: SiteBriefing) => {
router.push(`/ko/construction/project/bidding/site-briefings/${item.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(item: SiteBriefing) => {
router.push(`/ko/construction/project/bidding/site-briefings/${item.id}?mode=edit`);
},
[router]
);
// ===== 추가 핸들러 =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/project/bidding/site-briefings?mode=new');
}, [router]);

View File

@@ -11,9 +11,9 @@
* - 삭제 기능 (deleteConfirmMessage로 AlertDialog 대체)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useMemo, useEffect } from 'react';
import { Building2, HardHat, AlertCircle, Pencil } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -61,7 +61,8 @@ export default function SiteManagementListClient({
initialData = [],
initialStats,
}: SiteManagementListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit } = useListHandlers<Site>('construction/order/site-management');
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'construction' | 'unregistered'>('all');
@@ -81,21 +82,6 @@ export default function SiteManagementListClient({
}
}, [initialStats]);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(site: Site) => {
router.push(`/ko/construction/order/site-management/${site.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(site: Site) => {
router.push(`/ko/construction/order/site-management/${site.id}?mode=edit`);
},
[router]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<Site> = useMemo(
() => ({

View File

@@ -12,8 +12,8 @@
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { ClipboardCheck, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -38,6 +38,7 @@ import {
deleteStructureReview,
deleteStructureReviews,
} from './actions';
import { formatDate } from '@/utils/date';
// 테이블 컬럼 정의
const tableColumns = [
@@ -60,12 +61,6 @@ const MOCK_PARTNERS = [
{ value: '3', label: '회사명C' },
];
// 날짜 포맷
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
return dateStr.split('T')[0];
}
interface StructureReviewListClientProps {
initialData?: StructureReview[];
initialStats?: StructureReviewStats;
@@ -75,7 +70,10 @@ export default function StructureReviewListClient({
initialData = [],
initialStats,
}: StructureReviewListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick, handleEdit, router } = useListHandlers<StructureReview>(
'construction/order/structure-review'
);
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
@@ -95,21 +93,7 @@ export default function StructureReviewListClient({
}
}, [initialStats]);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: StructureReview) => {
router.push(`/ko/construction/order/structure-review/${item.id}?mode=view`);
},
[router]
);
const handleEdit = useCallback(
(item: StructureReview) => {
router.push(`/ko/construction/order/structure-review/${item.id}?mode=edit`);
},
[router]
);
// ===== 추가 핸들러 =====
const handleCreate = useCallback(() => {
router.push('/ko/construction/order/structure-review?mode=new');
}, [router]);

View File

@@ -42,6 +42,8 @@ import {
deleteUtility,
deleteUtilities,
} from './actions';
import { formatNumber } from '@/utils/formatAmount';
import { formatDate } from '@/utils/date';
// 테이블 컬럼 정의
const tableColumns = [
@@ -59,17 +61,6 @@ const tableColumns = [
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
];
// 날짜 포맷
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
return dateStr.split('T')[0];
}
// 금액 포맷
function formatAmount(amount: number): string {
return amount.toLocaleString('ko-KR') + '원';
}
interface UtilityManagementListClientProps {
initialData?: Utility[];
initialStats?: UtilityStats;
@@ -350,7 +341,7 @@ export default function UtilityManagementListClient({
<TableCell>{item.constructionPM}</TableCell>
<TableCell>{item.utilityType}</TableCell>
<TableCell>{formatDate(item.scheduledDate)}</TableCell>
<TableCell className="text-right">{formatAmount(item.amount)}</TableCell>
<TableCell className="text-right">{formatNumber(item.amount)}</TableCell>
<TableCell>{item.workTeamLeader}</TableCell>
<TableCell>{formatDate(item.constructionStartDate)}</TableCell>
<TableCell className="text-center">
@@ -391,7 +382,7 @@ export default function UtilityManagementListClient({
details={[
{ label: '거래처', value: item.partnerName },
{ label: '공사PM', value: item.constructionPM },
{ label: '금액', value: formatAmount(item.amount) },
{ label: '금액', value: `${formatNumber(item.amount)}` },
]}
/>
),

View File

@@ -11,9 +11,9 @@
* - 등록/삭제 버튼 없음 (조회 전용)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useState, useMemo, useEffect } from 'react';
import { Users, Eye, FileText, Clock, CheckCircle } from 'lucide-react';
import { useListHandlers } from '@/hooks/useListHandlers';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -77,7 +77,8 @@ export default function WorkerStatusListClient({
initialData = [],
initialStats,
}: WorkerStatusListClientProps) {
const router = useRouter();
// ===== 공통 핸들러 Hook =====
const { handleRowClick } = useListHandlers<WorkerStatus>('construction/project/worker-status');
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'all_contract' | 'pending' | 'completed'>('all');
@@ -97,21 +98,6 @@ export default function WorkerStatusListClient({
}
}, [initialStats]);
// ===== 핸들러 =====
const handleRowClick = useCallback(
(item: WorkerStatus) => {
router.push(`/ko/construction/project/worker-status/${item.id}?mode=view`);
},
[router]
);
const handleViewDetail = useCallback(
(item: WorkerStatus) => {
router.push(`/ko/construction/project/worker-status/${item.id}?mode=view`);
},
[router]
);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<WorkerStatus> = useMemo(
() => ({
@@ -387,7 +373,7 @@ export default function WorkerStatusListClient({
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleViewDetail(item);
handleRowClick(item);
}}
>
<Eye className="h-4 w-4" />
@@ -422,7 +408,7 @@ export default function WorkerStatusListClient({
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleViewDetail, searchQuery]
[startDate, endDate, activeStatTab, stats, handleRowClick, searchQuery]
);
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;

View File

@@ -10,7 +10,10 @@ interface DayCellProps {
isCurrentMonth: boolean;
isToday: boolean;
isSelected: boolean;
isWeekend: boolean;
isSaturday: boolean;
isSunday: boolean;
isHoliday?: boolean;
holidayName?: string | null;
isPast: boolean;
badge?: DayBadge;
onClick: (date: Date) => void;
@@ -28,18 +31,24 @@ export function DayCell({
isCurrentMonth,
isToday,
isSelected,
isWeekend,
isSaturday,
isSunday,
isHoliday = false,
holidayName,
isPast,
badge,
onClick,
}: DayCellProps) {
const dayNumber = format(date, 'd');
const badgeColor = badge?.color || 'red';
const isRedDay = isSunday || isHoliday; // 일요일 또는 공휴일 → 빨간색
const isBlueDay = isSaturday && !isHoliday; // 토요일(공휴일 아닌 경우) → 파란색
return (
<button
type="button"
onClick={() => onClick(date)}
title={holidayName || undefined}
className={cn(
'relative w-full h-8 flex items-center justify-center',
'text-sm font-medium transition-colors rounded-md',
@@ -48,10 +57,14 @@ export function DayCell({
isCurrentMonth ? 'text-foreground' : 'text-muted-foreground/40',
// 지난 일자 - 더 명확한 회색 (현재 월에서만)
isPast && isCurrentMonth && !isToday && !isSelected && 'text-gray-400',
// 주말 색상 (지난 일자가 아닌 경우만)
isWeekend && isCurrentMonth && !isPast && 'text-red-500',
// 지난 주말 - 연한 색상
isWeekend && isCurrentMonth && isPast && !isToday && !isSelected && 'text-red-300',
// 일요일/공휴일 색상 (지난 일자가 아닌 경우만) → 빨간색
isRedDay && isCurrentMonth && !isPast && 'text-red-500',
// 토요일 색상 (지난 일자가 아닌 경우만) → 파란색
isBlueDay && isCurrentMonth && !isPast && 'text-blue-500',
// 지난 일요일/공휴일 - 연한 색상
isRedDay && isCurrentMonth && isPast && !isToday && !isSelected && 'text-red-300',
// 지난 토요일 - 연한 파란색
isBlueDay && isCurrentMonth && isPast && !isToday && !isSelected && 'text-blue-300',
// 오늘 - 굵은 글씨 (외곽선은 부모 셀에 적용) - 보라색
isToday && !isSelected && 'font-bold text-purple-600',
// 선택됨 - 배경색 하이라이트 - 보라색

View File

@@ -19,7 +19,8 @@ import {
getEventsForDate,
getBadgeForDate,
} from './utils';
import { getDay } from 'date-fns';
import { getDay, format } from 'date-fns';
import { isHoliday, getHolidayName } from '@/constants/calendarEvents';
/**
* 월간 뷰 컴포넌트
@@ -65,9 +66,14 @@ export function MonthView({
{/* 요일 헤더 */}
<div className="grid grid-cols-7 mb-1">
{weekdayHeaders.map((day, index) => {
const isWeekend =
(weekStartsOn === 0 && (index === 0 || index === 6)) ||
(weekStartsOn === 1 && (index === 5 || index === 6));
// 일요일: weekStartsOn=0이면 index 0, weekStartsOn=1이면 index 6
const isSunday =
(weekStartsOn === 0 && index === 0) ||
(weekStartsOn === 1 && index === 6);
// 토요일: weekStartsOn=0이면 index 6, weekStartsOn=1이면 index 5
const isSaturday =
(weekStartsOn === 0 && index === 6) ||
(weekStartsOn === 1 && index === 5);
return (
<div
@@ -75,7 +81,8 @@ export function MonthView({
className={cn(
'text-center text-xs font-semibold py-2',
'text-muted-foreground',
isWeekend && 'text-red-400'
isSunday && 'text-red-400',
isSaturday && 'text-blue-400'
)}
>
{day}
@@ -168,7 +175,11 @@ function WeekRow({
{/* 날짜 셀들 */}
{weekDays.map((date, colIndex) => {
const dayOfWeek = getDay(date);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isSaturday = dayOfWeek === 6;
const isSunday = dayOfWeek === 0;
const dateStr = format(date, 'yyyy-MM-dd');
const isHolidayDate = isHoliday(dateStr);
const holidayName = getHolidayName(dateStr);
const badge = getBadgeForDate(badges, date);
const hiddenCount = hiddenEventCounts.get(colIndex) || 0;
const dayEvents = getEventsForDate(events, date);
@@ -201,7 +212,10 @@ function WeekRow({
isCurrentMonth={isCurrMonth}
isToday={isToday}
isSelected={isSelected}
isWeekend={isWeekend}
isSaturday={isSaturday}
isSunday={isSunday}
isHoliday={isHolidayDate}
holidayName={holidayName}
isPast={isPast}
badge={badge}
onClick={onDateClick}

View File

@@ -17,6 +17,7 @@ import {
} from './utils';
import { format, getDay } from 'date-fns';
import { ko } from 'date-fns/locale';
import { isHoliday, getHolidayName } from '@/constants/calendarEvents';
/**
* 주간 뷰 컴포넌트
@@ -81,7 +82,13 @@ export function WeekView({
<div className="grid grid-cols-7 mb-1">
{weekDays.map((date) => {
const dayOfWeek = getDay(date);
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
const isSaturday = dayOfWeek === 6;
const isSunday = dayOfWeek === 0;
const dateStr = format(date, 'yyyy-MM-dd');
const isHolidayDate = isHoliday(dateStr);
const holidayName = getHolidayName(dateStr);
const isRedDay = isSunday || isHolidayDate; // 일요일 또는 공휴일 → 빨간색
const isBlueDay = isSaturday && !isHolidayDate; // 토요일(공휴일 아닌 경우) → 파란색
const isToday = checkIsToday(date);
return (
@@ -91,11 +98,13 @@ export function WeekView({
'text-center py-2',
'flex flex-col items-center'
)}
title={holidayName || undefined}
>
<span
className={cn(
'text-xs font-semibold text-muted-foreground',
isWeekend && 'text-red-400'
isRedDay && 'text-red-400',
isBlueDay && 'text-blue-400'
)}
>
{format(date, 'E', { locale: ko })}
@@ -103,7 +112,8 @@ export function WeekView({
<span
className={cn(
'text-lg font-bold mt-0.5',
isWeekend && 'text-red-500',
isRedDay && 'text-red-500',
isBlueDay && 'text-blue-500',
isToday && 'bg-primary text-primary-foreground rounded-full w-8 h-8 flex items-center justify-center'
)}
>

View File

@@ -3,14 +3,36 @@
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { DayPicker } from "react-day-picker";
import { format, getDay } from "date-fns";
import { cn } from "./utils";
import { buttonVariants } from "./button";
import { isHoliday } from "@/constants/calendarEvents";
// 토요일 체크 함수 (react-day-picker modifier용)
const saturdayMatcher = (date: Date) => {
const dateStr = format(date, "yyyy-MM-dd");
// 토요일이면서 공휴일이 아닌 경우만
return getDay(date) === 6 && !isHoliday(dateStr);
};
// 일요일 체크 함수 (react-day-picker modifier용)
const sundayMatcher = (date: Date) => {
return getDay(date) === 0;
};
// 공휴일 체크 함수 (react-day-picker modifier용)
const holidayMatcher = (date: Date) => {
const dateStr = format(date, "yyyy-MM-dd");
return isHoliday(dateStr);
};
function Calendar({
className,
classNames,
showOutsideDays = true,
modifiers,
modifiersClassNames,
...props
}: React.ComponentProps<typeof DayPicker>) {
// Hydration 불일치 방지: 클라이언트에서만 렌더링
@@ -27,8 +49,25 @@ function Calendar({
);
}
// 공휴일/토요일/일요일 modifiers 병합
const mergedModifiers = {
saturday: saturdayMatcher,
sunday: sundayMatcher,
holiday: holidayMatcher,
...modifiers,
};
const mergedModifiersClassNames = {
saturday: "text-blue-500 font-semibold",
sunday: "text-red-500 font-semibold",
holiday: "text-red-500 font-semibold",
...modifiersClassNames,
};
return (
<DayPicker
modifiers={mergedModifiers}
modifiersClassNames={mergedModifiersClassNames}
showOutsideDays={showOutsideDays}
className={cn("p-0 w-full", className)}
classNames={{

View File

@@ -0,0 +1,102 @@
/**
* 달력 이벤트 상수 (공휴일, 세금 마감일)
* - 매년 연말에 다음 연도 데이터 추가 필요
* - 추후 API 연동 시 이 파일을 API 호출로 대체
*/
export type CalendarEventType = 'holiday' | 'tax';
export interface CalendarEvent {
date: string;
name: string;
type: CalendarEventType;
}
// ============================================
// 2026년 공휴일
// ============================================
export const HOLIDAYS_2026: CalendarEvent[] = [
{ date: '2026-01-01', name: '신정', type: 'holiday' },
{ date: '2026-02-16', name: '설날 연휴', type: 'holiday' },
{ date: '2026-02-17', name: '설날', type: 'holiday' },
{ date: '2026-02-18', name: '설날 연휴', type: 'holiday' },
{ date: '2026-03-01', name: '삼일절', type: 'holiday' },
{ date: '2026-03-02', name: '대체공휴일(삼일절)', type: 'holiday' }, // 3/1이 일요일
{ date: '2026-05-05', name: '어린이날', type: 'holiday' },
{ date: '2026-05-24', name: '부처님오신날', type: 'holiday' },
{ date: '2026-06-06', name: '현충일', type: 'holiday' },
{ date: '2026-08-15', name: '광복절', type: 'holiday' },
{ date: '2026-08-17', name: '대체공휴일(광복절)', type: 'holiday' }, // 8/15가 토요일
{ date: '2026-09-24', name: '추석 연휴', type: 'holiday' },
{ date: '2026-09-25', name: '추석', type: 'holiday' },
{ date: '2026-09-26', name: '추석 연휴', type: 'holiday' },
{ date: '2026-10-03', name: '개천절', type: 'holiday' },
{ date: '2026-10-09', name: '한글날', type: 'holiday' },
{ date: '2026-12-25', name: '성탄절', type: 'holiday' },
];
// ============================================
// 2026년 주요 세금 마감일
// ============================================
export const TAX_DEADLINES_2026: CalendarEvent[] = [
{ date: '2026-01-26', name: '부가세 2기 확정신고', type: 'tax' }, // 1/25가 일요일 → 1/26
{ date: '2026-03-31', name: '법인세 신고', type: 'tax' },
{ date: '2026-06-01', name: '종합소득세 신고', type: 'tax' }, // 5/31이 일요일 → 6/1
{ date: '2026-07-27', name: '부가세 1기 확정신고', type: 'tax' }, // 7/25가 토요일 → 7/27
];
// ============================================
// 연도별 통합 데이터
// ============================================
export const CALENDAR_EVENTS: Record<number, CalendarEvent[]> = {
2026: [...HOLIDAYS_2026, ...TAX_DEADLINES_2026],
};
// ============================================
// 유틸리티 함수
// ============================================
/**
* 특정 날짜의 모든 이벤트 조회
*/
export function getEventsForDate(date: string): CalendarEvent[] {
const year = parseInt(date.substring(0, 4), 10);
const events = CALENDAR_EVENTS[year] || [];
return events.filter((e) => e.date === date);
}
/**
* 공휴일 여부 확인
*/
export function isHoliday(date: string): boolean {
const year = parseInt(date.substring(0, 4), 10);
const events = CALENDAR_EVENTS[year] || [];
return events.some((e) => e.date === date && e.type === 'holiday');
}
/**
* 세금 마감일 여부 확인
*/
export function isTaxDeadline(date: string): boolean {
const year = parseInt(date.substring(0, 4), 10);
const events = CALENDAR_EVENTS[year] || [];
return events.some((e) => e.date === date && e.type === 'tax');
}
/**
* 공휴일명 조회
*/
export function getHolidayName(date: string): string | null {
const events = getEventsForDate(date);
const holiday = events.find((e) => e.type === 'holiday');
return holiday?.name || null;
}
/**
* 세금 마감일명 조회
*/
export function getTaxDeadlineName(date: string): string | null {
const events = getEventsForDate(date);
const tax = events.find((e) => e.type === 'tax');
return tax?.name || null;
}

View File

@@ -0,0 +1,40 @@
'use client';
import { useCallback } from 'react';
import { useRouter } from 'next/navigation';
/**
* 리스트 페이지 공통 핸들러 Hook
*
* 리스트 페이지에서 반복되는 라우팅 핸들러를 공통화
* - handleRowClick: 상세 보기 (mode=view)
* - handleEdit: 수정 모드 (mode=edit)
*
* @example
* const { handleRowClick, handleEdit } = useListHandlers<Contract>(
* 'construction/project/contract'
* );
*
* // 자동으로 /ko/ 접두사 추가
* // handleRowClick(item) → /ko/construction/project/contract/{id}?mode=view
* // handleEdit(item) → /ko/construction/project/contract/{id}?mode=edit
*/
export function useListHandlers<T extends { id: string }>(basePath: string) {
const router = useRouter();
const handleRowClick = useCallback(
(item: T) => {
router.push(`/ko/${basePath}/${item.id}?mode=view`);
},
[router, basePath]
);
const handleEdit = useCallback(
(item: T) => {
router.push(`/ko/${basePath}/${item.id}?mode=edit`);
},
[router, basePath]
);
return { handleRowClick, handleEdit, router };
}

View File

@@ -56,4 +56,29 @@ export function formatDateForInput(dateStr: string | null | undefined): string {
// 로컬 시간대 기준 YYYY-MM-DD 형식으로 변환
return getLocalDateString(date);
}
/**
* 날짜 표시용 포맷 (YYYY-MM-DD)
* @example formatDate("2025-01-06T00:00:00.000Z") // "2025-01-06"
* @example formatDate(null) // "-"
*/
export function formatDate(dateStr: string | null | undefined): string {
if (!dateStr) return '-';
// ISO string에서 날짜 부분만 추출, 또는 이미 YYYY-MM-DD면 그대로
return dateStr.split('T')[0];
}
/**
* 날짜 범위 포맷 ("시작 ~ 종료")
* @example formatDateRange("2025-01-01", "2025-12-31") // "2025-01-01 ~ 2025-12-31"
*/
export function formatDateRange(
startDate: string | null | undefined,
endDate: string | null | undefined
): string {
const start = formatDate(startDate);
const end = formatDate(endDate);
if (start === '-' && end === '-') return '-';
return `${start} ~ ${end}`;
}

View File

@@ -5,6 +5,16 @@
* 1만원 이상: "1,000만원"
*/
/**
* 단순 숫자 포맷 (천단위 콤마만, 단위 없음)
* @example formatNumber(1234567) // "1,234,567"
* @example formatNumber(null) // "-"
*/
export function formatNumber(value: number | null | undefined): string {
if (value == null || isNaN(value)) return '-';
return new Intl.NumberFormat('ko-KR').format(value);
}
export function formatAmount(amount: number): string {
// NaN, undefined, null 처리
if (amount == null || isNaN(amount)) {