Files
sam-react-prod/src/components/business/construction/management/ConstructionManagementListClient.tsx
유병철 1f6b592b9f feat(WEB): 리스트 페이지 UI 레이아웃 표준화
- 공통 레이아웃 패턴 적용: [달력] → [프리셋] → [검색창] → [버튼들]
- beforeTableContent → headerActions + createButton 마이그레이션
- DateRangeSelector extraActions prop 활용하여 검색창 통합
- PricingListClient 테이블 행 클릭 → 상세 이동 기능 추가
- 회계 관련 페이지 (입금/출금/매입/매출/어음/카드/예상지출 등) 정리
- 건설 관련 페이지 검색 영역 정리
- 부모 메뉴 리다이렉트 컴포넌트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 22:04:36 +09:00

562 lines
20 KiB
TypeScript

'use client';
/**
* 시공관리 - UniversalListPage 마이그레이션
*
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
* - 클라이언트 사이드 필터링 (검색, 필터, 정렬)
* - ScheduleCalendar (beforeTableContent)
* - 달력 전용 필터 (calendarSiteFilters, calendarWorkTeamFilters)
* - 달력 날짜 선택 필터링 (selectedCalendarDate)
* - Stats 카드 클릭 필터링 (activeStatTab)
* - DateRangeSelector (dateRangeSelector config)
* - filterConfig (multi 4개 + single 2개)
* - 삭제 기능 없음 (수정만 가능)
*/
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { HardHat, Pencil, Clock, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { MobileCard } from '@/components/organisms/MobileCard';
import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar';
import {
UniversalListPage,
type UniversalListConfig,
type SelectionHandlers,
type RowClickHandlers,
type StatCard,
} from '@/components/templates/UniversalListPage';
import { format, isSameDay, startOfDay, parseISO } from 'date-fns';
import type {
ConstructionManagement,
ConstructionManagementStats,
} from './types';
import {
CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS,
CONSTRUCTION_MANAGEMENT_SORT_OPTIONS,
CONSTRUCTION_MANAGEMENT_STATUS_STYLES,
CONSTRUCTION_MANAGEMENT_STATUS_LABELS,
MOCK_CM_PARTNERS,
MOCK_CM_SITES,
MOCK_CM_CONSTRUCTION_PM,
MOCK_CM_WORK_TEAM_LEADERS,
getConstructionScheduleColor,
} from './types';
import {
getConstructionManagementList,
getConstructionManagementStats,
} from './actions';
// 테이블 컬럼 정의
const tableColumns = [
{ key: 'no', label: '번호', className: 'w-[50px] text-center' },
{ key: 'constructionNumber', label: '시공번호', className: 'w-[100px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'siteName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px]' },
{ key: 'workTeamLeader', label: '작업반장', className: 'w-[80px]' },
{ key: 'worker', label: '작업자', className: 'w-[80px]' },
{ key: 'constructionStartDate', label: '시공투입일', className: 'w-[100px]' },
{ key: 'constructionEndDate', label: '시공완료일', className: 'w-[100px]' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
interface ConstructionManagementListClientProps {
initialData?: ConstructionManagement[];
initialStats?: ConstructionManagementStats;
}
export default function ConstructionManagementListClient({
initialData = [],
initialStats,
}: ConstructionManagementListClientProps) {
const router = useRouter();
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [activeStatTab, setActiveStatTab] = useState<'all' | 'in_progress' | 'completed'>('all');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
// 달력 관련 상태
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
// 달력 전용 필터 (테이블 필터와 별도)
const [calendarSiteFilters, setCalendarSiteFilters] = useState<string[]>([]);
const [calendarWorkTeamFilters, setCalendarWorkTeamFilters] = useState<string[]>([]);
// 전체 데이터 (달력 이벤트용)
const [allConstructions, setAllConstructions] = useState<ConstructionManagement[]>(initialData);
// Stats 로드
useEffect(() => {
if (!initialStats) {
getConstructionManagementStats().then((result) => {
if (result.success && result.data) {
setStats(result.data);
}
});
}
}, [initialStats]);
// 필터 옵션 (memo)
const siteOptions: MultiSelectOption[] = useMemo(() =>
MOCK_CM_SITES.map(s => ({ value: s.value, label: s.label })),
[]);
const workTeamOptions: MultiSelectOption[] = useMemo(() =>
MOCK_CM_WORK_TEAM_LEADERS.map(l => ({ value: l.value, label: l.label })),
[]);
const partnerOptions: MultiSelectOption[] = useMemo(() =>
MOCK_CM_PARTNERS.map(p => ({ value: p.value, label: p.label })),
[]);
const constructionPMOptions: MultiSelectOption[] = useMemo(() =>
MOCK_CM_CONSTRUCTION_PM.map(pm => ({ value: pm.value, label: pm.label })),
[]);
// 달력 이벤트 데이터 (달력 전용 필터 적용)
const calendarEvents: ScheduleEvent[] = useMemo(() => {
return allConstructions
.filter((item) => {
// 현장 필터 (달력용)
if (calendarSiteFilters.length > 0) {
const matchingSite = MOCK_CM_SITES.find((s) => s.label === item.siteName);
if (!matchingSite || !calendarSiteFilters.includes(matchingSite.value)) {
return false;
}
}
// 작업반장 필터 (달력용)
if (calendarWorkTeamFilters.length > 0) {
const matchingLeader = MOCK_CM_WORK_TEAM_LEADERS.find((l) => l.label === item.workTeamLeader);
if (!matchingLeader || !calendarWorkTeamFilters.includes(matchingLeader.value)) {
return false;
}
}
return true;
})
.map((item) => ({
id: item.id,
title: `${item.workTeamLeader} - ${item.siteName} / ${item.constructionNumber}`,
startDate: item.periodStart,
endDate: item.periodEnd,
color: getConstructionScheduleColor(item.workTeamLeader),
status: item.status,
data: item,
}));
}, [allConstructions, calendarSiteFilters, calendarWorkTeamFilters]);
// 달력 뱃지 (사용 안 함)
const calendarBadges: DayBadge[] = [];
// 날짜 포맷
const formatDate = useCallback((dateStr: string | null) => {
if (!dateStr) return '-';
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)) {
setSelectedCalendarDate(null);
} else {
setSelectedCalendarDate(date);
}
}, [selectedCalendarDate]);
const handleCalendarEventClick = useCallback((event: ScheduleEvent) => {
if (event.data) {
router.push(`/ko/construction/project/construction-management/${event.id}?mode=view`);
}
}, [router]);
const handleCalendarMonthChange = useCallback((date: Date) => {
setCalendarDate(date);
}, []);
// 달력 필터 슬롯
const calendarFilterSlot = useMemo(() => (
<div className="flex items-center gap-2">
<MultiSelectCombobox
options={siteOptions}
value={calendarSiteFilters}
onChange={setCalendarSiteFilters}
placeholder="현장"
searchPlaceholder="현장 검색..."
className="w-[160px]"
/>
<MultiSelectCombobox
options={workTeamOptions}
value={calendarWorkTeamFilters}
onChange={setCalendarWorkTeamFilters}
placeholder="작업반장"
searchPlaceholder="작업반장 검색..."
className="w-[130px]"
/>
</div>
), [siteOptions, workTeamOptions, calendarSiteFilters, calendarWorkTeamFilters]);
// ===== UniversalListPage Config =====
const config: UniversalListConfig<ConstructionManagement> = useMemo(
() => ({
// 페이지 기본 정보
title: '시공관리',
description: '시공 스케줄 및 목록을 관리합니다',
icon: HardHat,
basePath: '/construction/project/construction-management',
// ID 추출
idField: 'id',
// API 액션
actions: {
getList: async () => {
const result = await getConstructionManagementList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
});
if (result.success && result.data) {
return {
success: true,
data: result.data.items,
totalCount: result.data.total,
};
}
return { success: false, error: result.error };
},
},
// 테이블 컬럼
columns: tableColumns,
// 클라이언트 사이드 필터링
clientSideFiltering: true,
itemsPerPage: 20,
// 데이터 변경 콜백 (달력 이벤트용)
onDataChange: (data) => setAllConstructions(data),
// 검색 필터
searchPlaceholder: '시공번호, 거래처, 현장명, 작업반장, 작업자 검색',
searchFilter: (item, searchValue) => {
const search = searchValue.toLowerCase();
return (
item.constructionNumber.toLowerCase().includes(search) ||
item.partnerName.toLowerCase().includes(search) ||
item.siteName.toLowerCase().includes(search) ||
item.workTeamLeader.toLowerCase().includes(search) ||
item.worker.toLowerCase().includes(search)
);
},
// 필터 설정
filterConfig: [
{ key: 'partners', label: '거래처', type: 'multi', options: partnerOptions },
{ key: 'sites', label: '현장명', type: 'multi', options: siteOptions },
{ key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions },
{ key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions },
{ key: 'status', label: '상태', type: 'single', options: CONSTRUCTION_MANAGEMENT_STATUS_OPTIONS.filter((o) => o.value !== 'all') },
{ key: 'sortBy', label: '정렬', type: 'single', options: CONSTRUCTION_MANAGEMENT_SORT_OPTIONS },
],
initialFilters: {
partners: [],
sites: [],
constructionPMs: [],
workTeamLeaders: [],
status: 'all',
sortBy: 'latest',
},
filterTitle: '시공관리 필터',
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'in_progress' && item.status !== 'in_progress') return false;
if (activeStatTab === 'completed' && item.status !== 'completed') return false;
// 거래처 필터 (다중선택)
const partnerFilters = filterValues.partners as string[];
if (partnerFilters?.length > 0) {
const matchingPartner = MOCK_CM_PARTNERS.find((p) => p.label === item.partnerName);
if (!matchingPartner || !partnerFilters.includes(matchingPartner.value)) {
return false;
}
}
// 현장명 필터 (다중선택)
const siteFilters = filterValues.sites as string[];
if (siteFilters?.length > 0) {
const matchingSite = MOCK_CM_SITES.find((s) => s.label === item.siteName);
if (!matchingSite || !siteFilters.includes(matchingSite.value)) {
return false;
}
}
// 공사PM 필터 (다중선택)
const pmFilters = filterValues.constructionPMs as string[];
if (pmFilters?.length > 0) {
const matchingPM = MOCK_CM_CONSTRUCTION_PM.find((p) => p.label === item.constructionPM);
if (!matchingPM || !pmFilters.includes(matchingPM.value)) {
return false;
}
}
// 작업반장 필터 (다중선택)
const teamLeaderFilters = filterValues.workTeamLeaders as string[];
if (teamLeaderFilters?.length > 0) {
const matchingLeader = MOCK_CM_WORK_TEAM_LEADERS.find((l) => l.label === item.workTeamLeader);
if (!matchingLeader || !teamLeaderFilters.includes(matchingLeader.value)) {
return false;
}
}
// 상태 필터 (단일선택)
const statusFilter = filterValues.status as string;
if (statusFilter && statusFilter !== 'all' && item.status !== statusFilter) {
return false;
}
// 달력 날짜 필터
if (selectedCalendarDate) {
const itemStart = startOfDay(parseISO(item.periodStart));
const itemEnd = startOfDay(parseISO(item.periodEnd));
const selected = startOfDay(selectedCalendarDate);
if (selected < itemStart || selected > itemEnd) {
return false;
}
}
return true;
});
},
// 커스텀 정렬 함수
customSortFn: (items, filterValues) => {
const sorted = [...items];
const sortBy = (filterValues.sortBy as string) || 'latest';
switch (sortBy) {
case 'register':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'completionDateDesc':
sorted.sort((a, b) => {
if (!a.constructionEndDate) return 1;
if (!b.constructionEndDate) return -1;
return new Date(b.constructionEndDate).getTime() - new Date(a.constructionEndDate).getTime();
});
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
default: // latest
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
break;
}
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// Stats 카드
computeStats: (): StatCard[] => [
{
label: '시공진행',
value: stats?.inProgress ?? 0,
icon: Clock,
iconColor: 'text-yellow-600',
onClick: () => setActiveStatTab('in_progress'),
isActive: activeStatTab === 'in_progress',
},
{
label: '시공완료',
value: stats?.completed ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('completed'),
isActive: activeStatTab === 'completed',
},
],
// 테이블 헤더 액션 (총건 + 달력 날짜 필터 해제)
tableHeaderActions: ({ totalCount }) => (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{totalCount}
{selectedCalendarDate && (
<span className="ml-2 text-primary">
({format(selectedCalendarDate, 'M/d')} )
</span>
)}
</span>
{selectedCalendarDate && (
<Button
variant="outline"
size="sm"
onClick={() => setSelectedCalendarDate(null)}
>
</Button>
)}
</div>
),
// 달력 섹션 (beforeTableContent)
beforeTableContent: (
<div className="w-full flex-shrink-0 mb-6">
<ScheduleCalendar
events={calendarEvents}
badges={calendarBadges}
currentDate={calendarDate}
selectedDate={selectedCalendarDate}
onDateClick={handleCalendarDateClick}
onEventClick={handleCalendarEventClick}
onMonthChange={handleCalendarMonthChange}
titleSlot="시공 스케줄"
filterSlot={calendarFilterSlot}
maxEventsPerDay={5}
weekStartsOn={0}
isLoading={false}
/>
</div>
),
// 테이블 행 렌더링
renderTableRow: (
item: ConstructionManagement,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<ConstructionManagement>
) => (
<TableRow
key={item.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(item)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{item.constructionNumber}</TableCell>
<TableCell>{item.partnerName}</TableCell>
<TableCell>{item.siteName}</TableCell>
<TableCell>{item.constructionPM}</TableCell>
<TableCell>{item.workTeamLeader}</TableCell>
<TableCell>{item.worker}</TableCell>
<TableCell>{formatDate(item.constructionStartDate)}</TableCell>
<TableCell>{formatDate(item.constructionEndDate)}</TableCell>
<TableCell className="text-center">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${CONSTRUCTION_MANAGEMENT_STATUS_STYLES[item.status]}`}>
{CONSTRUCTION_MANAGEMENT_STATUS_LABELS[item.status]}
</span>
</TableCell>
<TableCell className="text-center">
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
handleEdit(item);
}}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
),
// 모바일 카드 렌더링
renderMobileCard: (
item: ConstructionManagement,
index: number,
globalIndex: number,
handlers: SelectionHandlers & RowClickHandlers<ConstructionManagement>
) => (
<MobileCard
key={item.id}
title={item.siteName}
subtitle={item.constructionNumber}
badge={CONSTRUCTION_MANAGEMENT_STATUS_LABELS[item.status]}
badgeVariant="secondary"
badgeClassName={CONSTRUCTION_MANAGEMENT_STATUS_STYLES[item.status]}
isSelected={handlers.isSelected}
onToggle={handlers.onToggle}
onClick={() => handleRowClick(item)}
details={[
{ label: '거래처', value: item.partnerName },
{ label: '작업반장', value: item.workTeamLeader },
{ label: '작업자', value: item.worker || '-' },
{ label: '시공투입일', value: formatDate(item.constructionStartDate) },
{ label: '시공완료일', value: formatDate(item.constructionEndDate) },
]}
/>
),
}),
[
startDate,
endDate,
searchQuery,
activeStatTab,
stats,
selectedCalendarDate,
calendarEvents,
calendarBadges,
calendarDate,
calendarFilterSlot,
partnerOptions,
siteOptions,
constructionPMOptions,
workTeamOptions,
handleRowClick,
handleEdit,
handleCalendarDateClick,
handleCalendarEventClick,
handleCalendarMonthChange,
formatDate,
]
);
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}